diff --git a/backend/.env.example b/backend/.env.example index 070c7bc..4ee51a7 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -12,3 +12,10 @@ MINERU_PYTHON=/root/projects/GraphRAGAgent/mineru_mvp/.venv/bin/python # Linux: /home/user/GraphRAGAgent/mineru_mvp/pipeline.py # Windows: F:/GraphRAGAgent/mineru_mvp/pipeline.py MINERU_PIPELINE=/root/projects/GraphRAGAgent/mineru_mvp/pipeline.py + +# Keycloak OIDC (authentication) +KEYCLOAK_SERVER_URL=https://keycloak.plfai.cn +KEYCLOAK_REALM=plfai +KEYCLOAK_CLIENT_ID=graphrag-backend +KEYCLOAK_CLIENT_SECRET=your_keycloak_client_secret_here +KEYCLOAK_AUDIENCE=account diff --git a/backend/main.py b/backend/main.py index 0bcd2e7..96eb6d7 100644 --- a/backend/main.py +++ b/backend/main.py @@ -9,11 +9,12 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).parent)) from dotenv import load_dotenv -from fastapi import FastAPI +from fastapi import Depends, FastAPI from fastapi.middleware.cors import CORSMiddleware load_dotenv(Path(__file__).parent / ".env", override=True) +from middleware.auth import get_current_user from routers import documents, indexing, kg, query, search, system app = FastAPI( @@ -40,11 +41,16 @@ app.add_middleware( # search.router prefix="/search" → /api/v1/search # system.router no prefix → /api/v1/health, /api/v1/system/... PREFIX = "/api/v1" -app.include_router(documents.router, prefix=PREFIX) -app.include_router(indexing.router, prefix=PREFIX) -app.include_router(kg.router, prefix=PREFIX) -app.include_router(query.router, prefix=PREFIX) -app.include_router(search.router, prefix=PREFIX) +_auth = [Depends(get_current_user)] + +# Protected routes (require Keycloak authentication) +app.include_router(documents.router, prefix=PREFIX, dependencies=_auth) +app.include_router(indexing.router, prefix=PREFIX, dependencies=_auth) +app.include_router(kg.router, prefix=PREFIX, dependencies=_auth) +app.include_router(query.router, prefix=PREFIX, dependencies=_auth) +app.include_router(search.router, prefix=PREFIX, dependencies=_auth) + +# Public routes (no authentication required) app.include_router(system.router, prefix=PREFIX) diff --git a/backend/middleware/__init__.py b/backend/middleware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/middleware/auth.py b/backend/middleware/auth.py new file mode 100644 index 0000000..54f41b4 --- /dev/null +++ b/backend/middleware/auth.py @@ -0,0 +1,114 @@ +"""Keycloak JWT authentication middleware.""" +from __future__ import annotations + +import logging +import os +from pathlib import Path +from typing import Optional + +import httpx +from dotenv import load_dotenv +from fastapi import Depends, HTTPException, Request, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from jose import JWTError, jwt +from jose.constants import Algorithms + +load_dotenv(Path(__file__).parent.parent / ".env", override=True) + +logger = logging.getLogger(__name__) +security = HTTPBearer(auto_error=False) + +KEYCLOAK_SERVER = os.getenv("KEYCLOAK_SERVER_URL", "https://keycloak.plfai.cn") +KEYCLOAK_REALM = os.getenv("KEYCLOAK_REALM", "plfai") +KEYCLOAK_AUDIENCE = os.getenv("KEYCLOAK_AUDIENCE", "account") + +OIDC_CONFIG_URL = f"{KEYCLOAK_SERVER}/realms/{KEYCLOAK_REALM}/.well-known/openid-configuration" + +# Cached OIDC config + JWKS +_oidc_config: Optional[dict] = None +_jwks: Optional[dict] = None + + +async def _fetch_oidc_config() -> tuple[dict, dict]: + """Fetch OIDC config and JWKS, caching them.""" + global _oidc_config, _jwks + + if _oidc_config is None: + async with httpx.AsyncClient() as client: + resp = await client.get(OIDC_CONFIG_URL) + resp.raise_for_status() + _oidc_config = resp.json() + + jwks_resp = await client.get(_oidc_config["jwks_uri"]) + jwks_resp.raise_for_status() + _jwks = jwks_resp.json() + + return _oidc_config, _jwks # type: ignore[return-value] + + +async def get_current_user( + request: Request, + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), +) -> dict: + """Validate Bearer token and return user claims. Raises 401 if invalid.""" + if credentials is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing Authorization header", + ) + + token = credentials.credentials + + try: + _, jwks = await _fetch_oidc_config() + except Exception as e: + logger.error(f"Failed to fetch OIDC config: {e}") + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Auth service unavailable", + ) + + # Get kid from token header + try: + unverified = jwt.get_unverified_header(token) + except JWTError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token header", + ) + + kid = unverified.get("kid") + if not kid: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing kid in token", + ) + + # Find matching public key + key = None + for k in jwks.get("keys", []): + if k.get("kid") == kid: + key = k + break + + if key is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Unknown signing key", + ) + + # Verify signature + expiry + try: + payload = jwt.decode( + token, + key, + algorithms=[Algorithms.RS256], + audience=KEYCLOAK_AUDIENCE, + options={"verify_exp": True}, + ) + return payload + except JWTError as e: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=f"Invalid token: {e}", + ) diff --git a/frontend/package.json b/frontend/package.json index 9f6dee2..590944a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -47,6 +47,7 @@ "date-fns": "3.6.0", "embla-carousel-react": "8.6.0", "input-otp": "1.4.2", + "keycloak-js": "^26.2.4", "lucide-react": "0.487.0", "marked": "^17.0.4", "motion": "12.23.24", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 00dc0df..9fb4a56 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -128,6 +128,9 @@ importers: input-otp: specifier: 1.4.2 version: 1.4.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + keycloak-js: + specifier: ^26.2.4 + version: 26.2.4 lucide-react: specifier: 0.487.0 version: 0.487.0(react@19.2.7) @@ -2004,6 +2007,9 @@ packages: engines: {node: '>=6'} hasBin: true + keycloak-js@26.2.4: + resolution: {integrity: sha512-PnXpR3ubETGOt0B/Qt2lxmPbkZr5bc3vlQsOqDoTPPQsZRp7JjhTKxlJ187uWh8qJhvBab6Gsjb06a8ayOPfuw==} + lightningcss-darwin-arm64@1.30.1: resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==} engines: {node: '>= 12.0.0'} @@ -4233,6 +4239,8 @@ snapshots: json5@2.2.3: {} + keycloak-js@26.2.4: {} + lightningcss-darwin-arm64@1.30.1: optional: true diff --git a/frontend/public/silent-check-sso.html b/frontend/public/silent-check-sso.html new file mode 100644 index 0000000..b80af7f --- /dev/null +++ b/frontend/public/silent-check-sso.html @@ -0,0 +1,8 @@ + + +
+ + + diff --git a/frontend/src/app/api.ts b/frontend/src/app/api.ts index 1d71402..79b3e9f 100644 --- a/frontend/src/app/api.ts +++ b/frontend/src/app/api.ts @@ -32,11 +32,21 @@ async function request