This commit is contained in:
Dmitriy Dorofeev 2026-11-06 16:39:00 +09:00 • committed by GitHub
commit f44d7bdafe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 63 additions and 33 deletions

View file

@ -3871,6 +3871,7 @@ dependencies = [
"rstest",
"serde",
"sha2 0.10.9",
"strum",
"subtle",
"thiserror 2.0.19",
"tokio",

View file

@ -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

View file

@ -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

View file

@ -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,
}

View file

@ -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))

View file

@ -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:

View file

@ -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))

View file

@ -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,

View file

@ -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
);
}