Skip to content

Commit 9b2cd01

Browse files
committed
Merge branch 'develop' into feature/agent-loop
# Conflicts: # backend/pyproject.toml # backend/utils/context_utils.py
2 parents aff565a + 8961efc commit 9b2cd01

82 files changed

Lines changed: 2842 additions & 549 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

backend/agents/create_agent_info.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import logging
44
from typing import List, Optional
55
from urllib.parse import urljoin
6-
from datetime import datetime
76

87
from jinja2 import Template, StrictUndefined
98
from nexent.core.utils.observer import MessageObserver
@@ -485,7 +484,6 @@ async def create_agent_config(
485484
# Get skills list for prompt template
486485
skills = _get_skills_for_template(agent_id, tenant_id, version_no)
487486

488-
time_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
489487
is_manager = len(managed_agents) > 0 or len(external_a2a_agents) > 0
490488

491489
render_kwargs = {
@@ -500,7 +498,6 @@ async def create_agent_config(
500498
"APP_DESCRIPTION": app_description,
501499
"memory_list": memory_list,
502500
"knowledge_base_summary": knowledge_base_summary,
503-
"time": time_str,
504501
"user_id": user_id,
505502
}
506503
system_prompt = Template(prompt_template["system_prompt"], undefined=StrictUndefined).render(render_kwargs)
@@ -529,7 +526,6 @@ async def create_agent_config(
529526
few_shots=few_shots_prompt,
530527
app_name=app_name,
531528
app_description=app_description,
532-
time_str=time_str,
533529
user_id=user_id,
534530
language=language,
535531
is_manager=is_manager,

backend/apps/cas_app.py

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import html
2+
import logging
3+
from http import HTTPStatus
4+
from typing import Optional
5+
from urllib.parse import parse_qs, urlsplit
6+
7+
from fastapi import APIRouter, HTTPException, Query, Request
8+
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
9+
10+
from services.cas_service import (
11+
CAS_SERVER_URL,
12+
CasAuthenticationError,
13+
build_login_url,
14+
build_renew_url,
15+
get_cas_config,
16+
login_with_ticket,
17+
renew_with_ticket,
18+
revoke_from_logout_request,
19+
)
20+
21+
logger = logging.getLogger(__name__)
22+
router = APIRouter(prefix="/user/cas", tags=["cas"])
23+
24+
25+
@router.get("/config")
26+
async def config():
27+
return JSONResponse(
28+
status_code=HTTPStatus.OK,
29+
content={"message": "success", "data": get_cas_config()},
30+
)
31+
32+
33+
@router.get("/login")
34+
async def login(redirect: str = Query("/", description="URL to return to after login")):
35+
try:
36+
login_url = _require_cas_server_redirect(build_login_url(redirect))
37+
return RedirectResponse(url=login_url, status_code=HTTPStatus.FOUND)
38+
except CasAuthenticationError as exc:
39+
logger.warning("CAS login rejected: %s", exc)
40+
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="CAS login is not available")
41+
42+
43+
@router.get("/callback")
44+
async def callback(ticket: str = "", redirect: str = "/"):
45+
try:
46+
result = await login_with_ticket(ticket, redirect)
47+
return JSONResponse(
48+
status_code=HTTPStatus.OK,
49+
content={"message": "CAS login successful", "data": result},
50+
)
51+
except CasAuthenticationError as exc:
52+
logger.warning("CAS callback rejected: %s", exc)
53+
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="CAS authentication failed")
54+
except Exception as exc:
55+
logger.error(f"CAS callback failed: {exc}")
56+
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="CAS login failed")
57+
58+
59+
@router.post("/callback")
60+
async def callback_logout(request: Request, logout_request: Optional[str] = None):
61+
return await _handle_logout_request(request, logout_request, endpoint="callback")
62+
63+
64+
@router.get("/renew")
65+
async def renew():
66+
try:
67+
return RedirectResponse(url=build_renew_url(), status_code=HTTPStatus.FOUND)
68+
except CasAuthenticationError as exc:
69+
logger.warning("CAS renew rejected: %s", exc)
70+
return _renew_html(False, "CAS renew failed")
71+
72+
73+
@router.get("/renew_callback")
74+
async def renew_callback(ticket: str = ""):
75+
if not ticket:
76+
return _renew_html(False, "CAS session is not active")
77+
try:
78+
result = await renew_with_ticket(ticket)
79+
return JSONResponse(
80+
status_code=HTTPStatus.OK,
81+
content={"message": "CAS renew successful", "data": result},
82+
)
83+
except Exception as exc:
84+
logger.warning(f"CAS renew failed: {exc}")
85+
return _renew_html(False, "CAS renew failed")
86+
87+
88+
@router.post("/logout_callback")
89+
async def logout_callback(
90+
request: Request,
91+
logout_request: Optional[str] = None,
92+
):
93+
return await _handle_logout_request(request, logout_request, endpoint="logout_callback")
94+
95+
96+
async def _handle_logout_request(
97+
request: Request,
98+
logout_request: Optional[str] = None,
99+
endpoint: str = "unknown",
100+
):
101+
logout_request = await _extract_logout_request(request, logout_request)
102+
logger.info(
103+
"CAS SLO %s received logoutRequest: present=%s length=%s",
104+
endpoint,
105+
bool(logout_request),
106+
len(logout_request or ""),
107+
)
108+
result = revoke_from_logout_request(logout_request)
109+
logger.info("CAS SLO %s revoke result: %s", endpoint, result)
110+
return JSONResponse(
111+
status_code=HTTPStatus.OK,
112+
content={"message": "success", "data": result},
113+
)
114+
115+
116+
async def _extract_logout_request(request: Request, logout_request: Optional[str] = None) -> str:
117+
if logout_request:
118+
return logout_request
119+
120+
query_logout_request = request.query_params.get("logoutRequest") or request.query_params.get("logout_request")
121+
if query_logout_request:
122+
return query_logout_request
123+
124+
body = await request.body()
125+
raw_body = body.decode("utf-8") if body else ""
126+
if not raw_body:
127+
return ""
128+
129+
parsed = parse_qs(raw_body)
130+
return (parsed.get("logoutRequest") or parsed.get("logout_request") or [raw_body])[0]
131+
132+
133+
def _renew_html(success: bool, reason: str = "") -> HTMLResponse:
134+
status = "success" if success else "failed"
135+
safe_reason = html.escape(reason)
136+
return HTMLResponse(
137+
status_code=HTTPStatus.OK,
138+
content=f"""<!doctype html>
139+
<html><body><script>
140+
window.parent && window.parent.postMessage({{ type: "cas-renew-{status}", reason: "{safe_reason}" }}, window.location.origin);
141+
</script></body></html>""",
142+
)
143+
144+
145+
def _require_cas_server_redirect(url: str) -> str:
146+
parsed_url = urlsplit(url)
147+
parsed_cas = urlsplit(CAS_SERVER_URL)
148+
if (
149+
parsed_url.scheme not in {"http", "https"}
150+
or not parsed_url.netloc
151+
or parsed_url.scheme != parsed_cas.scheme
152+
or parsed_url.netloc != parsed_cas.netloc
153+
):
154+
logger.warning("Blocked CAS redirect outside configured server: %s", url)
155+
raise CasAuthenticationError("Invalid CAS redirect URL")
156+
return url

backend/apps/config_app.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from apps.monitoring_app import router as monitoring_router
3333
from apps.a2a_server_app import router as a2a_server_router
3434
from apps.haotian_app import router as haotian_router
35+
from apps.cas_app import router as cas_router
3536
from consts.const import IS_SPEED_MODE
3637
from services.prompt_template_service import sync_system_default_prompt_template
3738

@@ -73,6 +74,7 @@ async def sync_default_prompt_template_on_startup():
7374
app.include_router(user_management_router)
7475

7576
app.include_router(oauth_router)
77+
app.include_router(cas_router)
7678

7779
app.include_router(summary_router)
7880
app.include_router(prompt_router)

backend/apps/user_management_app.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,13 @@
1919
ValidationError,
2020
)
2121
from consts.error_code import ErrorCode
22+
from services.cas_service import build_logout_url, CasAuthenticationError
2223
from services.user_management_service import get_authorized_client, validate_token, \
2324
check_auth_service_health, signup_user_with_invitation, signin_user, refresh_user_token, \
2425
get_session_by_authorization, get_user_info, create_token, list_tokens_by_user, delete_token, \
2526
update_password
2627
from services.user_service import delete_user_and_cleanup
27-
from utils.auth_utils import get_current_user_id
28+
from utils.auth_utils import get_current_user_id, extract_session_id_from_authorization
2829

2930

3031
load_dotenv()
@@ -150,7 +151,18 @@ async def logout(request: Request):
150151
authorization = request.headers.get("Authorization")
151152
try:
152153
# Make logout idempotent: if no token or token expired, still return success
154+
session_id = None
155+
cas_logout_url = ""
153156
if authorization:
157+
session_id = extract_session_id_from_authorization(authorization)
158+
if session_id:
159+
from database.cas_session_db import revoke_cas_session_by_session_id
160+
161+
revoke_cas_session_by_session_id(session_id, actor="user")
162+
try:
163+
cas_logout_url = build_logout_url()
164+
except CasAuthenticationError as cas_err:
165+
logging.warning(f"CAS logout URL is unavailable: {str(cas_err)}")
154166
client = get_authorized_client(authorization)
155167
try:
156168
client.auth.sign_out()
@@ -159,7 +171,12 @@ async def logout(request: Request):
159171
logging.warning(
160172
f"Sign out encountered an error but will be ignored: {str(signout_err)}")
161173
return JSONResponse(status_code=HTTPStatus.OK,
162-
content={"message": "Logout successful"})
174+
content={
175+
"message": "Logout successful",
176+
"data": {
177+
"cas_logout_url": cas_logout_url
178+
}
179+
})
163180

164181
except Exception as e:
165182
logging.error(f"User logout failed: {str(e)}")
@@ -214,6 +231,10 @@ async def get_user_information(request: Request):
214231
if not user_info:
215232
raise UnauthorizedError("User information not found")
216233

234+
user_info["user"]["auth_provider"] = (
235+
"cas" if extract_session_id_from_authorization(authorization) else "local"
236+
)
237+
217238
return JSONResponse(status_code=HTTPStatus.OK,
218239
content={"message": "Success",
219240
"data": user_info})

backend/consts/const.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,31 @@ class VectorDatabaseType(str, Enum):
9090
OAUTH_CA_BUNDLE = os.getenv("OAUTH_CA_BUNDLE", "")
9191

9292

93+
# CAS SSO Configuration
94+
CAS_ENABLED = os.getenv("CAS_ENABLED", "false").lower() in ("true", "1", "yes", "on")
95+
CAS_SERVER_URL = os.getenv("CAS_SERVER_URL", "").rstrip("/")
96+
CAS_VALIDATE_PATH = os.getenv("CAS_VALIDATE_PATH", "/p3/serviceValidate")
97+
CAS_CALLBACK_BASE_URL = os.getenv("CAS_CALLBACK_BASE_URL", OAUTH_CALLBACK_BASE_URL).rstrip("/")
98+
# CAS login mode:
99+
# - disabled: disable CAS login entry and automatic CAS redirects.
100+
# - button: show CAS as an optional login entry.
101+
# - force: automatically redirect unauthenticated users to CAS login.
102+
CAS_LOGIN_MODE = os.getenv("CAS_LOGIN_MODE", "disabled").lower()
103+
CAS_USER_ATTRIBUTE = os.getenv("CAS_USER_ATTRIBUTE", "")
104+
CAS_EMAIL_ATTRIBUTE = os.getenv("CAS_EMAIL_ATTRIBUTE", "email")
105+
CAS_ROLE_ATTRIBUTE = os.getenv("CAS_ROLE_ATTRIBUTE", "role")
106+
CAS_TENANT_ATTRIBUTE = os.getenv("CAS_TENANT_ATTRIBUTE", "tenant_id")
107+
CAS_ROLE_MAP_JSON = os.getenv("CAS_ROLE_MAP_JSON", "")
108+
CAS_SESSION_MAX_AGE_SECONDS = int(os.getenv("CAS_SESSION_MAX_AGE_SECONDS", "3600") or 3600)
109+
LOCAL_SESSION_MAX_AGE_SECONDS = int(os.getenv("LOCAL_SESSION_MAX_AGE_SECONDS", "3600") or 3600)
110+
CAS_RENEW_BEFORE_SECONDS = int(os.getenv("CAS_RENEW_BEFORE_SECONDS", "300") or 300)
111+
CAS_RENEW_TIMEOUT_SECONDS = int(os.getenv("CAS_RENEW_TIMEOUT_SECONDS", "10") or 10)
112+
CAS_SYNTHETIC_EMAIL_DOMAIN = os.getenv("CAS_SYNTHETIC_EMAIL_DOMAIN", "cas.local")
113+
CAS_LOGOUT_URL = os.getenv("CAS_LOGOUT_URL", "")
114+
CAS_SSL_VERIFY = os.getenv("CAS_SSL_VERIFY", "true").lower() == "true"
115+
CAS_CA_BUNDLE = os.getenv("CAS_CA_BUNDLE", "")
116+
117+
93118
# ===== To be migrated to frontend configuration =====
94119
# Email Configuration
95120
IMAP_SERVER = os.getenv('IMAP_SERVER')

0 commit comments

Comments
 (0)