mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-14 09:03:16 +00:00
Merge f217c8f4dc into 08f2a98250
This commit is contained in:
commit
f44d7bdafe
9 changed files with 63 additions and 33 deletions
1
litellm-rust/Cargo.lock
generated
1
litellm-rust/Cargo.lock
generated
|
|
@ -3871,6 +3871,7 @@ dependencies = [
|
|||
"rstest",
|
||||
"serde",
|
||||
"sha2 0.10.9",
|
||||
"strum",
|
||||
"subtle",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ The Axum `authenticate` middleware currently accepts one Authorization header us
|
|||
|
||||
The gateway also inserts an MCP `Authorization` extension and a `SessionOwner` derived from the verified principal and credential reference. The MCP host checks the injected policy at initialization and before sending resolved upstream operations. Standalone MCP library hosts retain their existing explicitly trusted registry behavior when no policy is installed. Production mounts must install authentication and the policy extension together
|
||||
|
||||
Local UI login continues using axum-login and tower-sessions. Only after session and CSRF validation does `UiSession` expose an authenticated caller. Its credentials are restricted to session-info and logout operations, so the UI CSRF bearer cannot authorize inference. Future UI management routes must extend that explicit scope
|
||||
Local UI login continues using axum-login and tower-sessions. Only after session and CSRF validation does `UiSession` expose an authenticated caller. Its credentials are restricted to session-info, current-user lookup, and logout operations, so the UI CSRF bearer cannot authorize inference. Future UI management routes must extend that explicit scope
|
||||
|
||||
Authentication evidence and principals contain no raw token, password, request body, or mutable accounting state. Session ownership is scoped by principal authority, subject, verifier, and credential ID, so separate credentials do not silently share an MCP session. Credential rotation may retain ownership when the verifier preserves a stable credential ID. Scope and expiry checks still run for each operation
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ sha2.workspace = true
|
|||
subtle.workspace = true
|
||||
thiserror.workspace = true
|
||||
serde.workspace = true
|
||||
strum.workspace = true
|
||||
tower-sessions.workspace = true
|
||||
veil.workspace = true
|
||||
|
||||
|
|
|
|||
|
|
@ -14,9 +14,10 @@ pub enum McpAction {
|
|||
ReadResource(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
#[derive(Clone, Debug, Eq, PartialEq, strum::VariantArray)]
|
||||
pub enum UiAction {
|
||||
SessionInfo,
|
||||
CurrentUser,
|
||||
Logout,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -134,6 +134,7 @@ async fn session_identity(user: &UiUser) -> Result<AuthenticatedRequest, Error>
|
|||
},
|
||||
restrictions: Permissions::Only(Arc::from([
|
||||
AccessRequest::Ui(UiAction::SessionInfo),
|
||||
AccessRequest::Ui(UiAction::CurrentUser),
|
||||
AccessRequest::Ui(UiAction::Logout),
|
||||
])),
|
||||
},
|
||||
|
|
@ -166,6 +167,12 @@ mod tests {
|
|||
.await
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
identity
|
||||
.authorize(AccessRequest::Ui(UiAction::CurrentUser))
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
identity
|
||||
.authorize(AccessRequest::Ui(UiAction::Logout))
|
||||
|
|
|
|||
|
|
@ -30,11 +30,11 @@ Open `/ui/login/` to sign in. HTTPS cookies are enabled by default. For local HT
|
|||
|
||||
`POST /v2/login` accepts JSON credentials and returns the dashboard's token and redirect URL. `axum-login` handles authenticated sessions through `tower-sessions`. Login rotates the session, sets an absolute 24-hour expiry, and issues an HttpOnly `litellm_session` cookie. The JavaScript-readable `token` cookie contains display claims and a CSRF token in its `key` field. It contains neither the master key nor the session ID
|
||||
|
||||
`GET /session/info` and `POST /session/logout` require both the session cookie and `Authorization: Bearer <key>` from the dashboard token. The session's stored user determines authorization; client-supplied JWT claims do not. Logout deletes the server-side session and clears both cookies. JSON-only login prevents cross-origin form submissions, and login attempts use a shared token bucket with a burst of ten and a refill rate of ten per minute per gateway process
|
||||
`GET /session/info`, `GET /management/v1/users/me`, and `POST /session/logout` require both the session cookie and `Authorization: Bearer <key>` from the dashboard token. The current-user endpoint returns the authenticated local administrator's ID and role, with a null email, so the dashboard can validate the login. The session's stored user determines authorization. Client-supplied JWT claims do not. Logout deletes the server-side session and clears both cookies. JSON-only login prevents cross-origin form submissions, and login attempts use a shared token bucket with a burst of ten and a refill rate of ten per minute per gateway process
|
||||
|
||||
The gateway uses a process-local Moka session store with expiry and a capacity of 10,000 sessions. Restarts invalidate sessions, and capacity eviction can sign users out early. Multi-process deployments need a shared `SessionStore` supplied to the router
|
||||
|
||||
The current scope is local administrator login, sessions, discovery, and static assets. SSO, database-backed users, cross-origin workers, custom URL prefixes, and management APIs are not implemented. Dashboard screens that call management APIs still require those routes. UI request and response bodies are excluded from the inference gateway's debug body logger
|
||||
The current scope is local administrator login, sessions, current-user lookup, discovery, and static assets. SSO, database-backed users, cross-origin workers, custom URL prefixes, and other management APIs are not implemented. Dashboard screens that call those management APIs still require their routes. UI request and response bodies are excluded from the inference gateway's debug body logger
|
||||
|
||||
Run the regression suite from `litellm-rust` without a browser, dashboard build, running proxy, or provider credentials:
|
||||
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ pub fn router(
|
|||
Router::new()
|
||||
.route("/v2/login", post(session::login))
|
||||
.route("/session/info", get(session::info))
|
||||
.route("/management/v1/users/me", get(session::current_user))
|
||||
.route("/session/logout", post(session::logout))
|
||||
.layer(auth)
|
||||
.route("/.well-known/litellm-ui-config", get(session::discovery))
|
||||
|
|
|
|||
|
|
@ -48,6 +48,13 @@ struct SessionInfo {
|
|||
user_role: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CurrentUserResponse {
|
||||
user_id: String,
|
||||
user_role: &'static str,
|
||||
user_email: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct LogoutResponse {
|
||||
message: &'static str,
|
||||
|
|
@ -138,6 +145,21 @@ pub async fn info(
|
|||
}))
|
||||
}
|
||||
|
||||
pub async fn current_user(
|
||||
session: Result<UiSession, litellm_gateway_auth::Error>,
|
||||
) -> Result<impl IntoResponse, Error> {
|
||||
let session = session?;
|
||||
session
|
||||
.identity
|
||||
.authorize(AccessRequest::Ui(UiAction::CurrentUser))
|
||||
.await?;
|
||||
Ok(Json(CurrentUserResponse {
|
||||
user_id: session.user.username,
|
||||
user_role: "proxy_admin",
|
||||
user_email: None,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn logout(
|
||||
validated: Result<UiSession, litellm_gateway_auth::Error>,
|
||||
mut auth: UiAuthSession,
|
||||
|
|
|
|||
|
|
@ -108,8 +108,20 @@ async fn discovery_describes_local_login(app: App, #[case] path: &str) {
|
|||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::session_info(
|
||||
"/session/info",
|
||||
json!({"user_id": "admin", "user_role": "proxy_admin"})
|
||||
)]
|
||||
#[case::current_user(
|
||||
"/management/v1/users/me",
|
||||
json!({"user_id": "admin", "user_role": "proxy_admin", "user_email": null})
|
||||
)]
|
||||
#[tokio::test]
|
||||
async fn login_sets_bounded_session_and_requires_cookie_with_csrf(app: App) {
|
||||
async fn login_sets_bounded_session_and_requires_cookie_with_csrf(
|
||||
app: App,
|
||||
#[case] path: &str,
|
||||
#[case] expected: Value,
|
||||
) {
|
||||
let started = OffsetDateTime::now_utc();
|
||||
let response = login(&app, None).await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
|
@ -164,12 +176,9 @@ async fn login_sets_bounded_session_and_requires_cookie_with_csrf(app: App) {
|
|||
);
|
||||
assert!(record.expiry_date >= started + Duration::hours(24));
|
||||
assert!(record.expiry_date <= OffsetDateTime::now_utc() + Duration::hours(24));
|
||||
let response = protected(&app, "/session/info", "GET", &cookie.to_string(), csrf).await;
|
||||
let response = protected(&app, path, "GET", &cookie.to_string(), csrf).await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
body(response).await,
|
||||
json!({"user_id": "admin", "user_role": "proxy_admin"})
|
||||
);
|
||||
assert_eq!(body(response).await, expected);
|
||||
assert_eq!(
|
||||
app.store
|
||||
.load(&record.id)
|
||||
|
|
@ -180,13 +189,11 @@ async fn login_sets_bounded_session_and_requires_cookie_with_csrf(app: App) {
|
|||
record.expiry_date
|
||||
);
|
||||
assert_eq!(
|
||||
protected(&app, "/session/info", "GET", "", csrf)
|
||||
.await
|
||||
.status(),
|
||||
protected(&app, path, "GET", "", csrf).await.status(),
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
assert_eq!(
|
||||
protected(&app, "/session/info", "GET", &cookie.to_string(), "wrong")
|
||||
protected(&app, path, "GET", &cookie.to_string(), "wrong")
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::UNAUTHORIZED
|
||||
|
|
@ -194,8 +201,10 @@ async fn login_sets_bounded_session_and_requires_cookie_with_csrf(app: App) {
|
|||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::session_info("/session/info")]
|
||||
#[case::current_user("/management/v1/users/me")]
|
||||
#[tokio::test]
|
||||
async fn logout_revokes_only_the_presented_session(app: App) {
|
||||
async fn logout_revokes_only_the_presented_session(app: App, #[case] path: &str) {
|
||||
let first = login(&app, None).await;
|
||||
let first_cookie = session_cookie(&first);
|
||||
let first_claims = claims(first).await;
|
||||
|
|
@ -234,27 +243,15 @@ async fn logout_revokes_only_the_presented_session(app: App) {
|
|||
assert_eq!(removed.max_age(), Some(Duration::ZERO));
|
||||
assert_eq!(session_cookie(&response).max_age(), Some(Duration::ZERO));
|
||||
assert_eq!(
|
||||
protected(
|
||||
&app,
|
||||
"/session/info",
|
||||
"GET",
|
||||
&first_cookie.to_string(),
|
||||
first_csrf
|
||||
)
|
||||
.await
|
||||
.status(),
|
||||
protected(&app, path, "GET", &first_cookie.to_string(), first_csrf)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
assert_eq!(
|
||||
protected(
|
||||
&app,
|
||||
"/session/info",
|
||||
"GET",
|
||||
&second_cookie.to_string(),
|
||||
second_csrf
|
||||
)
|
||||
.await
|
||||
.status(),
|
||||
protected(&app, path, "GET", &second_cookie.to_string(), second_csrf)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue