simplifying the auth and creating strict separation

This commit is contained in:
2025-09-09 06:38:37 +02:00
parent bd7109e31b
commit 1b36a94034
16 changed files with 335 additions and 287 deletions

View File

@@ -16,7 +16,7 @@ from ..v1.prompt_templates import router as prompt_templates_router
from ..v1.security import router as security_router from ..v1.security import router as security_router
from ..v1.plugin_registry import router as plugin_registry_router from ..v1.plugin_registry import router as plugin_registry_router
from ..v1.platform import router as platform_router from ..v1.platform import router as platform_router
from ..v1.llm import router as llm_router from ..v1.llm_internal import router as llm_internal_router
from ..v1.chatbot import router as chatbot_router from ..v1.chatbot import router as chatbot_router
# Create internal API router # Create internal API router
@@ -61,8 +61,8 @@ internal_api_router.include_router(security_router, prefix="/security", tags=["i
# Include plugin registry routes (frontend plugin management) # Include plugin registry routes (frontend plugin management)
internal_api_router.include_router(plugin_registry_router, prefix="/plugins", tags=["internal-plugins"]) internal_api_router.include_router(plugin_registry_router, prefix="/plugins", tags=["internal-plugins"])
# Include LLM routes (frontend LLM service access) # Include internal LLM routes (frontend LLM service access with JWT auth)
internal_api_router.include_router(llm_router, prefix="/llm", tags=["internal-llm"]) internal_api_router.include_router(llm_internal_router, prefix="/llm", tags=["internal-llm"])
# Include chatbot routes (frontend chatbot management) # Include chatbot routes (frontend chatbot management)
internal_api_router.include_router(chatbot_router, prefix="/chatbot", tags=["internal-chatbot"]) internal_api_router.include_router(chatbot_router, prefix="/chatbot", tags=["internal-chatbot"])

View File

@@ -126,62 +126,13 @@ class ModelsResponse(BaseModel):
data: List[ModelInfo] data: List[ModelInfo]
# Hybrid authentication function # Authentication: Public API endpoints should use require_api_key
async def get_auth_context( # Internal API endpoints should use get_current_user from core.security
request: Request,
db: AsyncSession = Depends(get_db)
) -> Dict[str, Any]:
"""Get authentication context from either API key or JWT token"""
# Try API key authentication first
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
token = auth_header[7:]
# Check if it's an API key (starts with ce_ prefix)
if token.startswith(settings.API_KEY_PREFIX):
try:
context = await get_api_key_context(request, db)
if context:
return context
except Exception as e:
logger.warning(f"API key authentication failed: {e}")
else:
# Try JWT token authentication
try:
from app.core.security import get_current_user
# Create a fake credentials object for JWT validation
from fastapi.security import HTTPAuthorizationCredentials
credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials=token)
user = await get_current_user(credentials, db)
if user:
return {
"user": user,
"auth_type": "jwt",
"api_key": None
}
except Exception as e:
logger.warning(f"JWT authentication failed: {e}")
# Try X-API-Key header
api_key = request.headers.get("X-API-Key")
if api_key:
try:
context = await get_api_key_context(request, db)
if context:
return context
except Exception as e:
logger.warning(f"X-API-Key authentication failed: {e}")
# No valid authentication found
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Valid API key or authentication token required"
)
# Endpoints # Endpoints
@router.get("/models", response_model=ModelsResponse) @router.get("/models", response_model=ModelsResponse)
async def list_models( async def list_models(
context: Dict[str, Any] = Depends(get_auth_context), context: Dict[str, Any] = Depends(require_api_key),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""List available models""" """List available models"""
@@ -220,7 +171,7 @@ async def list_models(
@router.post("/models/invalidate-cache") @router.post("/models/invalidate-cache")
async def invalidate_models_cache_endpoint( async def invalidate_models_cache_endpoint(
context: Dict[str, Any] = Depends(get_auth_context), context: Dict[str, Any] = Depends(require_api_key),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""Invalidate models cache (admin only)""" """Invalidate models cache (admin only)"""
@@ -249,7 +200,7 @@ async def invalidate_models_cache_endpoint(
async def create_chat_completion( async def create_chat_completion(
request_body: Request, request_body: Request,
chat_request: ChatCompletionRequest, chat_request: ChatCompletionRequest,
context: Dict[str, Any] = Depends(get_auth_context), context: Dict[str, Any] = Depends(require_api_key),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""Create chat completion with budget enforcement""" """Create chat completion with budget enforcement"""
@@ -604,7 +555,7 @@ async def create_embedding(
@router.get("/health") @router.get("/health")
async def llm_health_check( async def llm_health_check(
context: Dict[str, Any] = Depends(get_auth_context) context: Dict[str, Any] = Depends(require_api_key)
): ):
"""Health check for LLM service""" """Health check for LLM service"""
try: try:
@@ -686,7 +637,7 @@ async def get_usage_stats(
@router.get("/budget/status") @router.get("/budget/status")
async def get_budget_status( async def get_budget_status(
request: Request, request: Request,
context: Dict[str, Any] = Depends(get_auth_context), context: Dict[str, Any] = Depends(require_api_key),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""Get current budget status and usage analytics""" """Get current budget status and usage analytics"""

View File

@@ -0,0 +1,111 @@
"""
Internal LLM API endpoints - for frontend use with JWT authentication
"""
import logging
from typing import Dict, Any, List
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.database import get_db
from app.core.security import get_current_user
from app.services.llm.service import llm_service
from app.api.v1.llm import get_cached_models # Reuse the caching logic
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/models")
async def list_models(
current_user: Dict[str, Any] = Depends(get_current_user)
) -> Dict[str, List[Dict[str, Any]]]:
"""
List available LLM models for authenticated users
"""
try:
models = await get_cached_models()
return {"data": models}
except Exception as e:
logger.error(f"Failed to list models: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve models"
)
@router.get("/providers/status")
async def get_provider_status(
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""
Get status of all LLM providers for authenticated users
"""
try:
provider_status = await llm_service.get_provider_status()
return {
"object": "provider_status",
"data": {
name: {
"provider": status.provider,
"status": status.status,
"latency_ms": status.latency_ms,
"success_rate": status.success_rate,
"last_check": status.last_check.isoformat(),
"error_message": status.error_message,
"models_available": status.models_available
}
for name, status in provider_status.items()
}
}
except Exception as e:
logger.error(f"Failed to get provider status: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve provider status"
)
@router.get("/health")
async def health_check(
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""
Get LLM service health status for authenticated users
"""
try:
health = await llm_service.health_check()
return {
"status": health["status"],
"providers": health.get("providers", {}),
"timestamp": health.get("timestamp")
}
except Exception as e:
logger.error(f"Health check failed: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Health check failed"
)
@router.get("/metrics")
async def get_metrics(
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""
Get LLM service metrics for authenticated users
"""
try:
metrics = await llm_service.get_metrics()
return {
"object": "metrics",
"data": metrics
}
except Exception as e:
logger.error(f"Failed to get metrics: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve metrics"
)

View File

@@ -6,12 +6,13 @@ from typing import Dict, Any, List
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from app.services.module_manager import module_manager, ModuleConfig from app.services.module_manager import module_manager, ModuleConfig
from app.core.logging import log_api_request from app.core.logging import log_api_request
from app.core.security import get_current_user
router = APIRouter() router = APIRouter()
@router.get("/") @router.get("/")
async def list_modules(): async def list_modules(current_user: Dict[str, Any] = Depends(get_current_user)):
"""Get list of all discovered modules with their status (enabled and disabled)""" """Get list of all discovered modules with their status (enabled and disabled)"""
log_api_request("list_modules", {}) log_api_request("list_modules", {})
@@ -70,7 +71,7 @@ async def list_modules():
@router.get("/status") @router.get("/status")
async def get_modules_status(): async def get_modules_status(current_user: Dict[str, Any] = Depends(get_current_user)):
"""Get comprehensive module status - CONSOLIDATED endpoint""" """Get comprehensive module status - CONSOLIDATED endpoint"""
log_api_request("get_modules_status", {}) log_api_request("get_modules_status", {})
@@ -134,7 +135,7 @@ async def get_modules_status():
@router.get("/{module_name}") @router.get("/{module_name}")
async def get_module_info(module_name: str): async def get_module_info(module_name: str, current_user: Dict[str, Any] = Depends(get_current_user)):
"""Get detailed information about a specific module""" """Get detailed information about a specific module"""
log_api_request("get_module_info", {"module_name": module_name}) log_api_request("get_module_info", {"module_name": module_name})
@@ -187,7 +188,7 @@ async def get_module_info(module_name: str):
@router.post("/{module_name}/enable") @router.post("/{module_name}/enable")
async def enable_module(module_name: str): async def enable_module(module_name: str, current_user: Dict[str, Any] = Depends(get_current_user)):
"""Enable a module""" """Enable a module"""
log_api_request("enable_module", {"module_name": module_name}) log_api_request("enable_module", {"module_name": module_name})
@@ -212,7 +213,7 @@ async def enable_module(module_name: str):
@router.post("/{module_name}/disable") @router.post("/{module_name}/disable")
async def disable_module(module_name: str): async def disable_module(module_name: str, current_user: Dict[str, Any] = Depends(get_current_user)):
"""Disable a module""" """Disable a module"""
log_api_request("disable_module", {"module_name": module_name}) log_api_request("disable_module", {"module_name": module_name})
@@ -237,7 +238,7 @@ async def disable_module(module_name: str):
@router.post("/all/reload") @router.post("/all/reload")
async def reload_all_modules(): async def reload_all_modules(current_user: Dict[str, Any] = Depends(get_current_user)):
"""Reload all modules""" """Reload all modules"""
log_api_request("reload_all_modules", {}) log_api_request("reload_all_modules", {})
@@ -271,7 +272,7 @@ async def reload_all_modules():
@router.post("/{module_name}/reload") @router.post("/{module_name}/reload")
async def reload_module(module_name: str): async def reload_module(module_name: str, current_user: Dict[str, Any] = Depends(get_current_user)):
"""Reload a specific module""" """Reload a specific module"""
log_api_request("reload_module", {"module_name": module_name}) log_api_request("reload_module", {"module_name": module_name})
@@ -290,7 +291,7 @@ async def reload_module(module_name: str):
@router.post("/{module_name}/restart") @router.post("/{module_name}/restart")
async def restart_module(module_name: str): async def restart_module(module_name: str, current_user: Dict[str, Any] = Depends(get_current_user)):
"""Restart a specific module (alias for reload)""" """Restart a specific module (alias for reload)"""
log_api_request("restart_module", {"module_name": module_name}) log_api_request("restart_module", {"module_name": module_name})
@@ -309,7 +310,7 @@ async def restart_module(module_name: str):
@router.post("/{module_name}/start") @router.post("/{module_name}/start")
async def start_module(module_name: str): async def start_module(module_name: str, current_user: Dict[str, Any] = Depends(get_current_user)):
"""Start a specific module (enable and load)""" """Start a specific module (enable and load)"""
log_api_request("start_module", {"module_name": module_name}) log_api_request("start_module", {"module_name": module_name})
@@ -331,7 +332,7 @@ async def start_module(module_name: str):
@router.post("/{module_name}/stop") @router.post("/{module_name}/stop")
async def stop_module(module_name: str): async def stop_module(module_name: str, current_user: Dict[str, Any] = Depends(get_current_user)):
"""Stop a specific module (disable and unload)""" """Stop a specific module (disable and unload)"""
log_api_request("stop_module", {"module_name": module_name}) log_api_request("stop_module", {"module_name": module_name})
@@ -353,7 +354,7 @@ async def stop_module(module_name: str):
@router.get("/{module_name}/stats") @router.get("/{module_name}/stats")
async def get_module_stats(module_name: str): async def get_module_stats(module_name: str, current_user: Dict[str, Any] = Depends(get_current_user)):
"""Get module statistics""" """Get module statistics"""
log_api_request("get_module_stats", {"module_name": module_name}) log_api_request("get_module_stats", {"module_name": module_name})
@@ -380,7 +381,7 @@ async def get_module_stats(module_name: str):
@router.post("/{module_name}/execute") @router.post("/{module_name}/execute")
async def execute_module_action(module_name: str, request_data: Dict[str, Any]): async def execute_module_action(module_name: str, request_data: Dict[str, Any], current_user: Dict[str, Any] = Depends(get_current_user)):
"""Execute a module action through the interceptor pattern""" """Execute a module action through the interceptor pattern"""
log_api_request("execute_module_action", {"module_name": module_name, "action": request_data.get("action")}) log_api_request("execute_module_action", {"module_name": module_name, "action": request_data.get("action")})
@@ -442,7 +443,7 @@ async def execute_module_action(module_name: str, request_data: Dict[str, Any]):
@router.get("/{module_name}/config") @router.get("/{module_name}/config")
async def get_module_config(module_name: str): async def get_module_config(module_name: str, current_user: Dict[str, Any] = Depends(get_current_user)):
"""Get module configuration schema and current values""" """Get module configuration schema and current values"""
log_api_request("get_module_config", {"module_name": module_name}) log_api_request("get_module_config", {"module_name": module_name})
@@ -493,7 +494,7 @@ async def get_module_config(module_name: str):
@router.post("/{module_name}/config") @router.post("/{module_name}/config")
async def update_module_config(module_name: str, config: dict): async def update_module_config(module_name: str, config: dict, current_user: Dict[str, Any] = Depends(get_current_user)):
"""Update module configuration""" """Update module configuration"""
log_api_request("update_module_config", {"module_name": module_name}) log_api_request("update_module_config", {"module_name": module_name})

View File

@@ -10,8 +10,9 @@ from fastapi.responses import JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.db.database import get_db from app.db.database import get_db
from app.services.api_key_auth import require_api_key
from app.api.v1.llm import ( from app.api.v1.llm import (
get_auth_context, get_cached_models, ModelsResponse, ModelInfo, get_cached_models, ModelsResponse, ModelInfo,
ChatCompletionRequest, EmbeddingRequest, create_chat_completion as llm_chat_completion, ChatCompletionRequest, EmbeddingRequest, create_chat_completion as llm_chat_completion,
create_embedding as llm_create_embedding create_embedding as llm_create_embedding
) )
@@ -41,7 +42,7 @@ def openai_error_response(message: str, error_type: str = "invalid_request_error
@router.get("/models", response_model=ModelsResponse) @router.get("/models", response_model=ModelsResponse)
async def list_models( async def list_models(
context: Dict[str, Any] = Depends(get_auth_context), context: Dict[str, Any] = Depends(require_api_key),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
""" """
@@ -84,7 +85,7 @@ async def list_models(
async def create_chat_completion( async def create_chat_completion(
request_body: Request, request_body: Request,
chat_request: ChatCompletionRequest, chat_request: ChatCompletionRequest,
context: Dict[str, Any] = Depends(get_auth_context), context: Dict[str, Any] = Depends(require_api_key),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
""" """
@@ -100,7 +101,7 @@ async def create_chat_completion(
@router.post("/embeddings") @router.post("/embeddings")
async def create_embedding( async def create_embedding(
request: EmbeddingRequest, request: EmbeddingRequest,
context: Dict[str, Any] = Depends(get_auth_context), context: Dict[str, Any] = Depends(require_api_key),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
""" """
@@ -116,7 +117,7 @@ async def create_embedding(
@router.get("/models/{model_id}") @router.get("/models/{model_id}")
async def retrieve_model( async def retrieve_model(
model_id: str, model_id: str,
context: Dict[str, Any] = Depends(get_auth_context), context: Dict[str, Any] = Depends(require_api_key),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
""" """

View File

@@ -9,6 +9,7 @@ from pydantic import BaseModel
from app.services.permission_manager import permission_registry, Permission, PermissionScope from app.services.permission_manager import permission_registry, Permission, PermissionScope
from app.core.logging import get_logger from app.core.logging import get_logger
from app.core.security import get_current_user
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -85,7 +86,7 @@ async def get_available_permissions(namespace: Optional[str] = None):
resource=perm.resource, resource=perm.resource,
action=perm.action, action=perm.action,
description=perm.description, description=perm.description,
conditions=perm.conditions conditions=getattr(perm, 'conditions', None)
) )
for perm in perms for perm in perms
] ]
@@ -131,7 +132,10 @@ async def validate_permissions(request: PermissionValidationRequest):
@router.post("/permissions/check", response_model=PermissionCheckResponse) @router.post("/permissions/check", response_model=PermissionCheckResponse)
async def check_permission(request: PermissionCheckRequest): async def check_permission(
request: PermissionCheckRequest,
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""Check if user has a specific permission""" """Check if user has a specific permission"""
try: try:
has_permission = permission_registry.check_permission( has_permission = permission_registry.check_permission(
@@ -168,7 +172,7 @@ async def get_module_permissions(module_id: str):
resource=perm.resource, resource=perm.resource,
action=perm.action, action=perm.action,
description=perm.description, description=perm.description,
conditions=perm.conditions conditions=getattr(perm, 'conditions', None)
) )
for perm in permissions for perm in permissions
] ]

View File

@@ -72,7 +72,9 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
try: try:
yield session yield session
except Exception as e: except Exception as e:
logger.error(f"Database session error: {e}") # Only log if there's an actual error, not normal operation
if str(e).strip(): # Only log if error message exists
logger.error(f"Database session error: {str(e)}", exc_info=True)
await session.rollback() await session.rollback()
raise raise
finally: finally:

View File

@@ -104,8 +104,10 @@ class SecurityMiddleware(BaseHTTPMiddleware):
"/favicon.ico", "/favicon.ico",
"/api/v1/auth/register", "/api/v1/auth/register",
"/api/v1/auth/login", "/api/v1/auth/login",
"/api/v1/auth/refresh", # Allow refresh endpoint
"/api-internal/v1/auth/register", "/api-internal/v1/auth/register",
"/api-internal/v1/auth/login", "/api-internal/v1/auth/login",
"/api-internal/v1/auth/refresh", # Allow refresh endpoint for internal API
"/", # Root endpoint "/", # Root endpoint
] ]

View File

@@ -4,7 +4,7 @@ Handles authentication, routing, and security for plugin APIs
""" """
import asyncio import asyncio
import time import time
import jwt from jose import jwt
from typing import Dict, Any, List, Optional, Tuple from typing import Dict, Any, List, Optional, Tuple
from fastapi import FastAPI, Request, Response, HTTPException, Depends from fastapi import FastAPI, Request, Response, HTTPException, Depends
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials

View File

@@ -2,7 +2,7 @@
Plugin Security and Authentication Service Plugin Security and Authentication Service
Handles plugin tokens, permissions, and security policies Handles plugin tokens, permissions, and security policies
""" """
import jwt from jose import jwt
import hashlib import hashlib
import secrets import secrets
import time import time

View File

@@ -33,6 +33,7 @@
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.0.0", "clsx": "^2.0.0",
"date-fns": "^2.30.0", "date-fns": "^2.30.0",
"events": "^3.3.0",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"lucide-react": "^0.294.0", "lucide-react": "^0.294.0",
"next": "^14.2.32", "next": "^14.2.32",
@@ -3973,6 +3974,15 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/extend": { "node_modules/extend": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",

View File

@@ -34,6 +34,7 @@
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.0.0", "clsx": "^2.0.0",
"date-fns": "^2.30.0", "date-fns": "^2.30.0",
"events": "^3.3.0",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"lucide-react": "^0.294.0", "lucide-react": "^0.294.0",
"next": "^14.2.32", "next": "^14.2.32",

View File

@@ -5,10 +5,10 @@ import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { useState } from "react" import { useState } from "react"
import { apiClient } from "@/lib/api-client" import { apiClient } from "@/lib/api-client"
import { decodeToken } from "@/lib/auth-utils" import { tokenManager } from "@/lib/token-manager"
export default function TestAuthPage() { export default function TestAuthPage() {
const { user, token, refreshToken, logout } = useAuth() const { user, logout, isAuthenticated } = useAuth()
const [testResult, setTestResult] = useState<string>("") const [testResult, setTestResult] = useState<string>("")
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
@@ -24,19 +24,19 @@ export default function TestAuthPage() {
} }
const getTokenInfo = () => { const getTokenInfo = () => {
if (!token) return "No token" const expiry = tokenManager.getTokenExpiry()
const refreshExpiry = tokenManager.getRefreshTokenExpiry()
const payload = decodeToken(token) if (!expiry) return "No token"
if (!payload) return "Invalid token"
const now = Math.floor(Date.now() / 1000) const now = new Date()
const timeUntilExpiry = payload.exp - now const timeUntilExpiry = Math.floor((expiry.getTime() - now.getTime()) / 1000)
const expiryDate = new Date(payload.exp * 1000)
return ` return `
Token expires in: ${Math.floor(timeUntilExpiry / 60)} minutes ${timeUntilExpiry % 60} seconds Token expires in: ${Math.floor(timeUntilExpiry / 60)} minutes ${timeUntilExpiry % 60} seconds
Expiry time: ${expiryDate.toLocaleString()} Access token expiry: ${expiry.toLocaleString()}
Token payload: ${JSON.stringify(payload, null, 2)} Refresh token expiry: ${refreshExpiry?.toLocaleString() || 'N/A'}
Authenticated: ${tokenManager.isAuthenticated()}
` `
} }
@@ -70,9 +70,9 @@ Token payload: ${JSON.stringify(payload, null, 2)}
<pre className="bg-gray-100 dark:bg-gray-800 p-4 rounded text-sm"> <pre className="bg-gray-100 dark:bg-gray-800 p-4 rounded text-sm">
{getTokenInfo()} {getTokenInfo()}
</pre> </pre>
{refreshToken && ( {isAuthenticated && (
<p className="mt-2 text-sm text-green-600"> <p className="mt-2 text-sm text-green-600">
Refresh token available - auto-refresh enabled Auto-refresh enabled - tokens will refresh automatically
</p> </p>
)} )}
</CardContent> </CardContent>

View File

@@ -1,16 +1,8 @@
"use client" "use client"
import { createContext, useContext, useState, useEffect, ReactNode, useRef } from "react" import { createContext, useContext, useState, useEffect, ReactNode } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { import { tokenManager } from "@/lib/token-manager"
isTokenExpired,
refreshAccessToken,
storeTokens,
getStoredTokens,
clearTokens,
setupTokenRefreshTimer,
decodeToken
} from "@/lib/auth-utils"
interface User { interface User {
id: string id: string
@@ -21,128 +13,107 @@ interface User {
interface AuthContextType { interface AuthContextType {
user: User | null user: User | null
token: string | null isAuthenticated: boolean
refreshToken: string | null
login: (email: string, password: string) => Promise<void> login: (email: string, password: string) => Promise<void>
logout: () => void logout: () => void
isLoading: boolean isLoading: boolean
refreshTokenIfNeeded: () => Promise<boolean>
} }
const AuthContext = createContext<AuthContextType | undefined>(undefined) const AuthContext = createContext<AuthContextType | undefined>(undefined)
export function AuthProvider({ children }: { children: ReactNode }) { export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null) const [user, setUser] = useState<User | null>(null)
const [token, setToken] = useState<string | null>(null)
const [refreshToken, setRefreshToken] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const router = useRouter() const router = useRouter()
const refreshTimerRef = useRef<NodeJS.Timeout | null>(null)
// Initialize auth state from localStorage // Initialize auth state and listen to token manager events
useEffect(() => { useEffect(() => {
const initAuth = async () => { const initAuth = async () => {
if (typeof window === "undefined") return // Check if we have valid tokens
if (tokenManager.isAuthenticated()) {
// Don't try to refresh on auth-related pages // Try to get user info
const isAuthPage = window.location.pathname === '/login' || await fetchUserInfo()
window.location.pathname === '/register' ||
window.location.pathname === '/forgot-password'
const { accessToken, refreshToken: storedRefreshToken } = getStoredTokens()
if (accessToken && storedRefreshToken) {
// Check if token is expired
if (isTokenExpired(accessToken)) {
// Only try to refresh if not on auth pages
if (!isAuthPage) {
// Try to refresh the token
const response = await refreshAccessToken(storedRefreshToken)
if (response) {
storeTokens(response.access_token, response.refresh_token)
setToken(response.access_token)
setRefreshToken(response.refresh_token)
// Decode token to get user info
const payload = decodeToken(response.access_token)
if (payload) {
const storedUser = localStorage.getItem("user")
if (storedUser) {
setUser(JSON.parse(storedUser))
}
}
// Setup refresh timer
setupRefreshTimer(response.access_token, response.refresh_token)
} else {
// Refresh failed, clear everything
clearTokens()
setUser(null)
setToken(null)
setRefreshToken(null)
}
} else {
// On auth pages with expired token, just clear it
clearTokens()
setUser(null)
setToken(null)
setRefreshToken(null)
}
} else {
// Token is still valid
setToken(accessToken)
setRefreshToken(storedRefreshToken)
const storedUser = localStorage.getItem("user")
if (storedUser) {
setUser(JSON.parse(storedUser))
}
// Setup refresh timer
setupRefreshTimer(accessToken, storedRefreshToken)
}
} }
setIsLoading(false) setIsLoading(false)
} }
// Set up event listeners
const handleTokensUpdated = () => {
// Tokens were updated (refreshed), update user if needed
if (!user) {
fetchUserInfo()
}
}
const handleTokensCleared = () => {
// Tokens were cleared, clear user
setUser(null)
}
const handleSessionExpired = (reason: string) => {
console.log('Session expired:', reason)
setUser(null)
// TokenManager and API client will handle redirect
}
const handleLogout = () => {
setUser(null)
router.push('/login')
}
// Register event listeners
tokenManager.on('tokensUpdated', handleTokensUpdated)
tokenManager.on('tokensCleared', handleTokensCleared)
tokenManager.on('sessionExpired', handleSessionExpired)
tokenManager.on('logout', handleLogout)
// Initialize
initAuth() initAuth()
}, [])
// Setup token refresh timer // Cleanup
const setupRefreshTimer = (accessToken: string, refreshTokenValue: string) => {
// Clear existing timer
if (refreshTimerRef.current) {
clearTimeout(refreshTimerRef.current)
}
refreshTimerRef.current = setupTokenRefreshTimer(
accessToken,
refreshTokenValue,
(newAccessToken) => {
setToken(newAccessToken)
},
() => {
// Refresh failed, logout user
logout()
}
)
}
// Cleanup timer on unmount
useEffect(() => {
return () => { return () => {
if (refreshTimerRef.current) { tokenManager.off('tokensUpdated', handleTokensUpdated)
clearTimeout(refreshTimerRef.current) tokenManager.off('tokensCleared', handleTokensCleared)
} tokenManager.off('sessionExpired', handleSessionExpired)
tokenManager.off('logout', handleLogout)
} }
}, []) }, [])
const fetchUserInfo = async () => {
try {
const token = await tokenManager.getAccessToken()
if (!token) return
const response = await fetch('/api-internal/v1/auth/me', {
headers: {
'Authorization': `Bearer ${token}`,
},
})
if (response.ok) {
const userData = await response.json()
const user = {
id: userData.id || userData.sub,
email: userData.email,
name: userData.name || userData.email,
role: userData.role || 'user',
}
setUser(user)
// Store user info for offline access
if (typeof window !== 'undefined') {
localStorage.setItem('user', JSON.stringify(user))
}
}
} catch (error) {
console.error('Failed to fetch user info:', error)
}
}
const login = async (email: string, password: string) => { const login = async (email: string, password: string) => {
setIsLoading(true) setIsLoading(true)
try { try {
// Call real backend login endpoint
const response = await fetch('/api-internal/v1/auth/login', { const response = await fetch('/api-internal/v1/auth/login', {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -158,38 +129,18 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const data = await response.json() const data = await response.json()
// Store tokens // Store tokens in TokenManager
storeTokens(data.access_token, data.refresh_token) tokenManager.setTokens(
data.access_token,
data.refresh_token,
data.expires_in
)
// Decode token to get user info // Fetch user info
const payload = decodeToken(data.access_token) await fetchUserInfo()
if (payload) {
// Fetch user details
const userResponse = await fetch('/api-internal/v1/auth/me', {
headers: {
'Authorization': `Bearer ${data.access_token}`,
},
})
if (userResponse.ok) {
const userData = await userResponse.json()
const user = {
id: userData.id || payload.sub,
email: userData.email || payload.email || email,
name: userData.name || userData.email || email,
role: userData.role || 'user',
}
localStorage.setItem("user", JSON.stringify(user))
setUser(user)
}
}
setToken(data.access_token) // Navigate to dashboard
setRefreshToken(data.refresh_token) router.push('/dashboard')
// Setup refresh timer
setupRefreshTimer(data.access_token, data.refresh_token)
} catch (error) { } catch (error) {
console.error('Login error:', error) console.error('Login error:', error)
@@ -200,56 +151,18 @@ export function AuthProvider({ children }: { children: ReactNode }) {
} }
const logout = () => { const logout = () => {
// Clear refresh timer tokenManager.logout()
if (refreshTimerRef.current) { // Token manager will emit 'logout' event which we handle above
clearTimeout(refreshTimerRef.current)
refreshTimerRef.current = null
}
// Clear state
setUser(null)
setToken(null)
setRefreshToken(null)
// Clear localStorage
clearTokens()
// Redirect to login
router.push("/login")
}
const refreshTokenIfNeeded = async (): Promise<boolean> => {
if (!token || !refreshToken) {
return false
}
if (isTokenExpired(token)) {
const response = await refreshAccessToken(refreshToken)
if (response) {
storeTokens(response.access_token, response.refresh_token)
setToken(response.access_token)
setRefreshToken(response.refresh_token)
setupRefreshTimer(response.access_token, response.refresh_token)
return true
} else {
logout()
return false
}
}
return true
} }
return ( return (
<AuthContext.Provider <AuthContext.Provider
value={{ value={{
user, user,
token, isAuthenticated: tokenManager.isAuthenticated(),
refreshToken,
login, login,
logout, logout,
isLoading, isLoading
refreshTokenIfNeeded
}} }}
> >
{children} {children}

View File

@@ -2,6 +2,8 @@
import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from "react" import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from "react"
import { apiClient } from "@/lib/api-client" import { apiClient } from "@/lib/api-client"
import { tokenManager } from "@/lib/token-manager"
import { usePathname } from "next/navigation"
interface Module { interface Module {
name: string name: string
@@ -34,11 +36,21 @@ const ModulesContext = createContext<ModulesContextType | undefined>(undefined)
export function ModulesProvider({ children }: { children: ReactNode }) { export function ModulesProvider({ children }: { children: ReactNode }) {
const [modules, setModules] = useState<Module[]>([]) const [modules, setModules] = useState<Module[]>([])
const [enabledModules, setEnabledModules] = useState<Set<string>>(new Set()) const [enabledModules, setEnabledModules] = useState<Set<string>>(new Set())
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [lastUpdated, setLastUpdated] = useState<Date | null>(null) const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
const pathname = usePathname()
// Check if we're on an auth page
const isAuthPage = pathname === '/login' || pathname === '/register' || pathname === '/forgot-password'
const fetchModules = useCallback(async () => { const fetchModules = useCallback(async () => {
// Don't fetch if we're on an auth page or not authenticated
if (isAuthPage || !tokenManager.isAuthenticated()) {
setIsLoading(false)
return
}
try { try {
setIsLoading(true) setIsLoading(true)
setError(null) setError(null)
@@ -57,11 +69,14 @@ export function ModulesProvider({ children }: { children: ReactNode }) {
setLastUpdated(new Date()) setLastUpdated(new Date())
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to load modules") // Only set error if we're authenticated (to avoid noise on auth pages)
if (tokenManager.isAuthenticated()) {
setError(err instanceof Error ? err.message : "Failed to load modules")
}
} finally { } finally {
setIsLoading(false) setIsLoading(false)
} }
}, []) }, [isAuthPage])
const refreshModules = useCallback(async () => { const refreshModules = useCallback(async () => {
await fetchModules() await fetchModules()
@@ -72,13 +87,22 @@ export function ModulesProvider({ children }: { children: ReactNode }) {
}, [enabledModules]) }, [enabledModules])
useEffect(() => { useEffect(() => {
fetchModules() // Only fetch if authenticated and not on auth page
if (!isAuthPage && tokenManager.isAuthenticated()) {
fetchModules()
}
// Set up periodic refresh every 30 seconds to catch module state changes // Set up periodic refresh every 30 seconds to catch module state changes
const interval = setInterval(fetchModules, 30000) // But only if authenticated
let interval: NodeJS.Timeout | null = null
if (!isAuthPage && tokenManager.isAuthenticated()) {
interval = setInterval(fetchModules, 30000)
}
return () => clearInterval(interval) return () => {
}, [fetchModules]) if (interval) clearInterval(interval)
}
}, [fetchModules, isAuthPage])
// Listen for custom events that indicate module state changes // Listen for custom events that indicate module state changes
useEffect(() => { useEffect(() => {
@@ -93,6 +117,34 @@ export function ModulesProvider({ children }: { children: ReactNode }) {
} }
}, [refreshModules]) }, [refreshModules])
// Listen for authentication changes
useEffect(() => {
const handleTokensUpdated = () => {
// When tokens are updated (user logs in), fetch modules
if (!isAuthPage) {
fetchModules()
}
}
const handleTokensCleared = () => {
// When tokens are cleared (user logs out), clear modules
setModules([])
setEnabledModules(new Set())
setError(null)
setLastUpdated(null)
}
tokenManager.on('tokensUpdated', handleTokensUpdated)
tokenManager.on('tokensCleared', handleTokensCleared)
tokenManager.on('logout', handleTokensCleared)
return () => {
tokenManager.off('tokensUpdated', handleTokensUpdated)
tokenManager.off('tokensCleared', handleTokensCleared)
tokenManager.off('logout', handleTokensCleared)
}
}, [fetchModules, isAuthPage])
return ( return (
<ModulesContext.Provider <ModulesContext.Provider
value={{ value={{

View File

@@ -97,7 +97,7 @@ interface PluginProviderProps {
} }
export const PluginProvider: React.FC<PluginProviderProps> = ({ children }) => { export const PluginProvider: React.FC<PluginProviderProps> = ({ children }) => {
const { user, token } = useAuth(); const { user, isAuthenticated } = useAuth();
const [installedPlugins, setInstalledPlugins] = useState<PluginInfo[]>([]); const [installedPlugins, setInstalledPlugins] = useState<PluginInfo[]>([]);
const [availablePlugins, setAvailablePlugins] = useState<AvailablePlugin[]>([]); const [availablePlugins, setAvailablePlugins] = useState<AvailablePlugin[]>([]);
const [pluginConfigurations, setPluginConfigurations] = useState<Record<string, PluginConfiguration>>({}); const [pluginConfigurations, setPluginConfigurations] = useState<Record<string, PluginConfiguration>>({});
@@ -108,7 +108,7 @@ export const PluginProvider: React.FC<PluginProviderProps> = ({ children }) => {
const [pluginComponents, setPluginComponents] = useState<Record<string, Record<string, React.ComponentType>>>({}); const [pluginComponents, setPluginComponents] = useState<Record<string, Record<string, React.ComponentType>>>({});
const apiRequest = async (endpoint: string, options: RequestInit = {}) => { const apiRequest = async (endpoint: string, options: RequestInit = {}) => {
if (!token) { if (!isAuthenticated) {
throw new Error('Authentication required'); throw new Error('Authentication required');
} }
@@ -129,7 +129,7 @@ export const PluginProvider: React.FC<PluginProviderProps> = ({ children }) => {
}; };
const refreshInstalledPlugins = useCallback(async () => { const refreshInstalledPlugins = useCallback(async () => {
if (!user || !token) { if (!user || !isAuthenticated) {
setError('Authentication required'); setError('Authentication required');
return; return;
} }
@@ -160,10 +160,10 @@ export const PluginProvider: React.FC<PluginProviderProps> = ({ children }) => {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [user, token]); }, [user, isAuthenticated]);
const searchAvailablePlugins = useCallback(async (query = '', tags: string[] = [], category = '') => { const searchAvailablePlugins = useCallback(async (query = '', tags: string[] = [], category = '') => {
if (!user || !token) { if (!user || !isAuthenticated) {
setError('Authentication required'); setError('Authentication required');
return; return;
} }
@@ -185,7 +185,7 @@ export const PluginProvider: React.FC<PluginProviderProps> = ({ children }) => {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [user, token]); }, [user, isAuthenticated]);
const installPlugin = useCallback(async (pluginId: string, version: string): Promise<boolean> => { const installPlugin = useCallback(async (pluginId: string, version: string): Promise<boolean> => {
try { try {
@@ -487,7 +487,7 @@ export const PluginProvider: React.FC<PluginProviderProps> = ({ children }) => {
// Load installed plugins on mount, but only when authenticated // Load installed plugins on mount, but only when authenticated
useEffect(() => { useEffect(() => {
if (user && token) { if (user && isAuthenticated) {
refreshInstalledPlugins(); refreshInstalledPlugins();
} else { } else {
// Clear plugin data when not authenticated // Clear plugin data when not authenticated
@@ -496,7 +496,7 @@ export const PluginProvider: React.FC<PluginProviderProps> = ({ children }) => {
setPluginConfigurations({}); setPluginConfigurations({});
setError(null); setError(null);
} }
}, [user, token, refreshInstalledPlugins]); }, [user, isAuthenticated, refreshInstalledPlugins]);
const value: PluginContextType = { const value: PluginContextType = {
// State // State