diff --git a/litellm-rust/Cargo.lock b/litellm-rust/Cargo.lock index e959f77917b..ac42d3e3396 100644 --- a/litellm-rust/Cargo.lock +++ b/litellm-rust/Cargo.lock @@ -3846,6 +3846,7 @@ dependencies = [ "rstest", "serde", "sha2 0.10.9", + "strum", "subtle", "thiserror 2.0.19", "tokio", diff --git a/litellm-rust/crates/gateway-auth/AGENTS.md b/litellm-rust/crates/gateway-auth/AGENTS.md index 0a4bc6450ae..5586dbc9878 100644 --- a/litellm-rust/crates/gateway-auth/AGENTS.md +++ b/litellm-rust/crates/gateway-auth/AGENTS.md @@ -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 diff --git a/litellm-rust/crates/gateway-auth/Cargo.toml b/litellm-rust/crates/gateway-auth/Cargo.toml index 27f20203843..864fe840acc 100644 --- a/litellm-rust/crates/gateway-auth/Cargo.toml +++ b/litellm-rust/crates/gateway-auth/Cargo.toml @@ -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 diff --git a/litellm-rust/crates/gateway-auth/src/authorization.rs b/litellm-rust/crates/gateway-auth/src/authorization.rs index 522fae64f3e..d7929f868e4 100644 --- a/litellm-rust/crates/gateway-auth/src/authorization.rs +++ b/litellm-rust/crates/gateway-auth/src/authorization.rs @@ -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, } diff --git a/litellm-rust/crates/gateway-auth/src/ui.rs b/litellm-rust/crates/gateway-auth/src/ui.rs index 3d655bde187..a7a3e68fa0e 100644 --- a/litellm-rust/crates/gateway-auth/src/ui.rs +++ b/litellm-rust/crates/gateway-auth/src/ui.rs @@ -134,6 +134,7 @@ async fn session_identity(user: &UiUser) -> Result }, 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)) diff --git a/litellm-rust/crates/gateway-ui/README.md b/litellm-rust/crates/gateway-ui/README.md index 82433c2239e..0b37437a70b 100644 --- a/litellm-rust/crates/gateway-ui/README.md +++ b/litellm-rust/crates/gateway-ui/README.md @@ -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 ` 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 ` 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: diff --git a/litellm-rust/crates/gateway-ui/src/lib.rs b/litellm-rust/crates/gateway-ui/src/lib.rs index 3e7ddb8f31e..e449aa54bd8 100644 --- a/litellm-rust/crates/gateway-ui/src/lib.rs +++ b/litellm-rust/crates/gateway-ui/src/lib.rs @@ -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)) diff --git a/litellm-rust/crates/gateway-ui/src/session.rs b/litellm-rust/crates/gateway-ui/src/session.rs index b5d847ad353..dc462fc6e57 100644 --- a/litellm-rust/crates/gateway-ui/src/session.rs +++ b/litellm-rust/crates/gateway-ui/src/session.rs @@ -48,6 +48,13 @@ struct SessionInfo { user_role: &'static str, } +#[derive(Serialize)] +struct CurrentUserResponse { + user_id: String, + user_role: &'static str, + user_email: Option, +} + #[derive(Serialize)] struct LogoutResponse { message: &'static str, @@ -138,6 +145,21 @@ pub async fn info( })) } +pub async fn current_user( + session: Result, +) -> Result { + 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, mut auth: UiAuthSession, diff --git a/litellm-rust/crates/gateway-ui/tests/routes.rs b/litellm-rust/crates/gateway-ui/tests/routes.rs index 95246f33a35..b91ea964575 100644 --- a/litellm-rust/crates/gateway-ui/tests/routes.rs +++ b/litellm-rust/crates/gateway-ui/tests/routes.rs @@ -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 ); }