Skip to main content

litellm_rs/server/middleware/
auth.rs

1//! Authentication middleware
2
3use crate::auth::{AUTHENTICATION_SERVICE_UNAVAILABLE_MESSAGE, AuthMethod};
4use crate::core::audit::middleware::record_authenticated_principal;
5use crate::core::models::{ApiKey, user::types::User};
6use crate::core::types::context::{RequestContext, SharedRequestContext};
7use crate::server::middleware::auth_rate_limiter::get_auth_rate_limiter;
8use crate::server::middleware::helpers::{
9    extract_auth_method_with_api_key_header, is_public_route, middleware_gateway_error_response,
10};
11use crate::server::middleware::rate_limit::{
12    AuthRateLimitReservation, RateLimitError, enforce_rate_limit_for_rejected_auth,
13    network_client_key, reserve_rate_limit_for_auth_attempt,
14};
15use crate::server::routes::ai::{self, api_key_allows_endpoint, check_permission};
16use crate::server::state::AppState;
17use crate::utils::error::gateway_error::GatewayError;
18use actix_web::body::EitherBody;
19use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform, forward_ready};
20use actix_web::{HttpMessage, HttpRequest, web};
21use futures::future::{Ready, ready};
22use std::collections::HashMap;
23use std::future::Future;
24use std::pin::Pin;
25use std::rc::Rc;
26use std::sync::Arc;
27use tracing::{debug, error, warn};
28
29/// Auth middleware for Actix-web
30pub struct AuthMiddleware;
31
32fn bypasses_header_auth(path: &str, auth_method: &AuthMethod) -> bool {
33    if path == "/health/ready" {
34        return matches!(auth_method, AuthMethod::None);
35    }
36    is_public_route(path) || path == "/auth/refresh"
37}
38
39impl<S, B> Transform<S, ServiceRequest> for AuthMiddleware
40where
41    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = actix_web::Error> + 'static,
42    S::Future: 'static,
43    B: 'static,
44{
45    type Response = ServiceResponse<EitherBody<B>>;
46    type Error = actix_web::Error;
47    type InitError = ();
48    type Transform = AuthMiddlewareService<S>;
49    type Future = Ready<Result<Self::Transform, Self::InitError>>;
50
51    fn new_transform(&self, service: S) -> Self::Future {
52        ready(Ok(AuthMiddlewareService {
53            service: Rc::new(service),
54        }))
55    }
56}
57
58/// Service implementation for auth middleware
59pub struct AuthMiddlewareService<S> {
60    service: Rc<S>,
61}
62
63impl<S, B> Service<ServiceRequest> for AuthMiddlewareService<S>
64where
65    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = actix_web::Error> + 'static,
66    S::Future: 'static,
67    B: 'static,
68{
69    type Response = ServiceResponse<EitherBody<B>>;
70    type Error = actix_web::Error;
71    type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>>>>;
72
73    forward_ready!(service);
74
75    fn call(&self, mut req: ServiceRequest) -> Self::Future {
76        let service = Rc::clone(&self.service);
77
78        Box::pin(async move {
79            let app_state = match req.app_data::<web::Data<AppState>>().cloned() {
80                Some(state) => state,
81                None => {
82                    return Err(actix_web::error::ErrorInternalServerError(
83                        "Missing application state",
84                    ));
85                }
86            };
87            let cfg = app_state.config.load();
88            let enable_jwt = cfg.auth().enable_jwt;
89            let enable_api_key = cfg.auth().enable_api_key;
90            let api_key_header = cfg.auth().api_key_header.clone();
91            let rate_limit_enabled = cfg.gateway.rate_limit.enabled;
92            let rate_limit_rpm = cfg.gateway.rate_limit.effective_rpm();
93            let trusted_proxies = cfg.server().trusted_proxies.clone();
94
95            let context = build_request_context(&mut req);
96            let auth_method =
97                extract_auth_method_with_api_key_header(req.headers(), api_key_header.as_str());
98            let is_public = bypasses_header_auth(req.path(), &auth_method);
99            let client_id = get_client_identifier(&req, &trusted_proxies);
100            let network_rate_limit_enabled = rate_limit_enabled && client_id.is_some();
101            let rate_limiter = get_auth_rate_limiter();
102
103            if is_public {
104                insert_request_context(&mut req, context);
105                return service
106                    .call(req)
107                    .await
108                    .map(ServiceResponse::map_into_left_body);
109            }
110
111            let auth_enabled = enable_jwt || enable_api_key;
112            if !auth_enabled {
113                // Fail closed: both auth methods are disabled. Only allow the
114                // request through when anonymous access was explicitly opted
115                // into. AuthConfig::validate() already rejects this combination,
116                // but guard here as defense in depth in case validation was
117                // bypassed.
118                if !cfg.auth().allow_anonymous {
119                    error!(
120                        "All authentication methods are disabled and allow_anonymous is false; \
121                         rejecting request to non-public route. Enable JWT or API key auth, or \
122                         set allow_anonymous: true (development only)."
123                    );
124                    return Ok(unauthorized_response(
125                        req,
126                        "Authentication is not configured",
127                    ));
128                }
129                insert_request_context(&mut req, context);
130                return service
131                    .call(req)
132                    .await
133                    .map(ServiceResponse::map_into_left_body);
134            }
135
136            // Apply the gateway RPM admission before waiting for a per-client
137            // credential-check slot, so queued authentication requests cannot
138            // bypass the general request-rate bound.
139            let mut auth_rate_limit_reservation = if requires_auth_verification(&auth_method) {
140                match reserve_gateway_rate_limit_before_auth(
141                    &req,
142                    network_rate_limit_enabled,
143                    rate_limit_rpm,
144                    &trusted_proxies,
145                )
146                .await
147                {
148                    Ok(reservation) => Some(reservation),
149                    Err(error) => return Ok(rate_limit_response(req, error)),
150                }
151            } else {
152                None
153            };
154
155            let auth_attempt = match rate_limiter
156                .reserve_network_attempt(client_id.as_deref())
157                .await
158            {
159                Ok(reservation) => reservation,
160                Err(wait_seconds) => {
161                    if auth_rate_limit_reservation.is_none()
162                        && let Err(error) = enforce_gateway_rate_limit_for_auth_rejection(
163                            &req,
164                            network_rate_limit_enabled,
165                            rate_limit_rpm,
166                            &trusted_proxies,
167                        )
168                        .await
169                    {
170                        return Ok(rate_limit_response(req, error));
171                    }
172                    return Ok(failed_attempt_rate_limit_response(req, wait_seconds));
173                }
174            };
175
176            let auth_method = match auth_method {
177                AuthMethod::Jwt(_) if !enable_jwt => {
178                    auth_attempt.record_failure();
179                    if auth_rate_limit_reservation.is_none()
180                        && let Err(error) = enforce_gateway_rate_limit_for_auth_rejection(
181                            &req,
182                            network_rate_limit_enabled,
183                            rate_limit_rpm,
184                            &trusted_proxies,
185                        )
186                        .await
187                    {
188                        return Ok(rate_limit_response(req, error));
189                    }
190                    return Ok(unauthorized_response(req, "JWT authentication disabled"));
191                }
192                AuthMethod::ApiKey(_) if !enable_api_key => {
193                    auth_attempt.record_failure();
194                    if auth_rate_limit_reservation.is_none()
195                        && let Err(error) = enforce_gateway_rate_limit_for_auth_rejection(
196                            &req,
197                            network_rate_limit_enabled,
198                            rate_limit_rpm,
199                            &trusted_proxies,
200                        )
201                        .await
202                    {
203                        return Ok(rate_limit_response(req, error));
204                    }
205                    return Ok(unauthorized_response(
206                        req,
207                        "API key authentication disabled",
208                    ));
209                }
210                other => other,
211            };
212
213            if matches!(auth_method, AuthMethod::None) {
214                auth_attempt.record_failure();
215                if let Err(error) = enforce_gateway_rate_limit_for_auth_rejection(
216                    &req,
217                    network_rate_limit_enabled,
218                    rate_limit_rpm,
219                    &trusted_proxies,
220                )
221                .await
222                {
223                    return Ok(rate_limit_response(req, error));
224                }
225                return Ok(unauthorized_response(req, "Missing authentication"));
226            }
227
228            match app_state.auth.authenticate(auth_method, context).await {
229                Ok(result) if result.success => {
230                    auth_attempt.release();
231                    // This is an IP-wide failure bucket, so a valid credential
232                    // cannot prove that earlier failures came from the same
233                    // principal. Preserve the failures to prevent an attacker
234                    // with one valid credential from resetting brute-force
235                    // attempts against another credential.
236                    debug!("Authentication succeeded");
237
238                    // Attach the authenticated principal before authorization
239                    // checks so audit middleware can attribute 403 responses.
240                    record_authenticated_principal(&req, &result.context);
241                    insert_request_context(&mut req, result.context);
242                    match api_key_allows_endpoint(result.api_key.as_ref(), req.path()) {
243                        Ok(true) => {}
244                        Ok(false) => {
245                            warn!("Authenticated API key is not permitted to access this endpoint");
246                            return Ok(forbidden_response(
247                                req,
248                                "API key is not permitted for this endpoint",
249                            ));
250                        }
251                        Err(error) => {
252                            warn!("Authenticated API key policy is invalid: {}", error);
253                            if let Some(reservation) = auth_rate_limit_reservation.take() {
254                                reservation.release().await;
255                            }
256                            return Ok(authentication_unavailable_response(req));
257                        }
258                    }
259                    if let Some(operation) = ai::operation_for_path(req.path())
260                        && !check_permission(
261                            result.user.as_ref(),
262                            result.api_key.as_ref(),
263                            operation,
264                        )
265                    {
266                        warn!(
267                            "Authenticated caller is not permitted to access AI operation '{}'",
268                            operation
269                        );
270                        return Ok(forbidden_response(
271                            req,
272                            "API key is not permitted for this operation",
273                        ));
274                    }
275
276                    if let Some(user) = result.user {
277                        req.extensions_mut().insert::<User>(user);
278                    }
279                    if let Some(api_key) = result.api_key {
280                        req.extensions_mut().insert::<ApiKey>(api_key);
281                    }
282
283                    if let Some(reservation) = auth_rate_limit_reservation.take() {
284                        reservation.release().await;
285                    }
286
287                    service
288                        .call(req)
289                        .await
290                        .map(ServiceResponse::map_into_left_body)
291                }
292                Ok(result) => {
293                    auth_attempt.record_failure();
294                    warn!(
295                        "Authentication failed: {}",
296                        result
297                            .error
298                            .clone()
299                            .unwrap_or_else(|| "unauthorized".to_string())
300                    );
301                    if auth_rate_limit_reservation.is_none()
302                        && let Err(error) = enforce_gateway_rate_limit_for_auth_rejection(
303                            &req,
304                            network_rate_limit_enabled,
305                            rate_limit_rpm,
306                            &trusted_proxies,
307                        )
308                        .await
309                    {
310                        return Ok(rate_limit_response(req, error));
311                    }
312                    Ok(unauthorized_response(
313                        req,
314                        result.error.unwrap_or_else(|| "Unauthorized".to_string()),
315                    ))
316                }
317                Err(err) => {
318                    auth_attempt.release();
319                    if let Some(reservation) = auth_rate_limit_reservation.take() {
320                        reservation.release().await;
321                    }
322                    error!(error = %err, "Authentication infrastructure failure");
323                    Ok(authentication_unavailable_response(req))
324                }
325            }
326        })
327    }
328}
329
330async fn enforce_gateway_rate_limit_for_auth_rejection(
331    req: &ServiceRequest,
332    enabled: bool,
333    requests_per_minute: u32,
334    trusted_proxies: &[String],
335) -> Result<(), RateLimitError> {
336    if !enabled {
337        return Ok(());
338    }
339
340    enforce_rate_limit_for_rejected_auth(req, requests_per_minute, trusted_proxies).await
341}
342
343async fn reserve_gateway_rate_limit_before_auth(
344    req: &ServiceRequest,
345    enabled: bool,
346    requests_per_minute: u32,
347    trusted_proxies: &[String],
348) -> Result<AuthRateLimitReservation, RateLimitError> {
349    if !enabled {
350        return Ok(AuthRateLimitReservation::noop());
351    }
352
353    reserve_rate_limit_for_auth_attempt(req, requests_per_minute, trusted_proxies).await
354}
355
356fn requires_auth_verification(auth_method: &AuthMethod) -> bool {
357    matches!(
358        auth_method,
359        AuthMethod::Jwt(_) | AuthMethod::ApiKey(_) | AuthMethod::Session(_)
360    )
361}
362
363fn unauthorized_response<B>(
364    req: ServiceRequest,
365    message: impl Into<String>,
366) -> ServiceResponse<EitherBody<B>> {
367    let message = message.into();
368    middleware_gateway_error_response(
369        req,
370        actix_web::error::ErrorUnauthorized(message.clone()),
371        GatewayError::Auth(message),
372    )
373}
374
375fn forbidden_response<B>(
376    req: ServiceRequest,
377    message: impl Into<String>,
378) -> ServiceResponse<EitherBody<B>> {
379    let message = message.into();
380    middleware_gateway_error_response(
381        req,
382        actix_web::error::ErrorForbidden(message.clone()),
383        GatewayError::Forbidden(message),
384    )
385}
386
387fn authentication_unavailable_response<B>(req: ServiceRequest) -> ServiceResponse<EitherBody<B>> {
388    if req.path() == "/v1/messages" {
389        return req
390            .into_response(ai::messages::error_response(&GatewayError::Internal(
391                AUTHENTICATION_SERVICE_UNAVAILABLE_MESSAGE.into(),
392            )))
393            .map_into_right_body();
394    }
395    if ai::is_openai_compatible_path(req.path()) {
396        return req
397            .into_response(ai::openai_internal_error_response(
398                AUTHENTICATION_SERVICE_UNAVAILABLE_MESSAGE,
399            ))
400            .map_into_right_body();
401    }
402
403    req.error_response(actix_web::error::ErrorInternalServerError(
404        AUTHENTICATION_SERVICE_UNAVAILABLE_MESSAGE,
405    ))
406    .map_into_right_body()
407}
408
409fn rate_limit_response<B>(
410    req: ServiceRequest,
411    error: RateLimitError,
412) -> ServiceResponse<EitherBody<B>> {
413    let gateway_error = error.gateway_error();
414    middleware_gateway_error_response(req, actix_web::Error::from(error), gateway_error)
415}
416
417fn failed_attempt_rate_limit_response<B>(
418    req: ServiceRequest,
419    wait_seconds: u64,
420) -> ServiceResponse<EitherBody<B>> {
421    let message = format!(
422        "Too many failed attempts. Try again in {} seconds",
423        wait_seconds
424    );
425    middleware_gateway_error_response(
426        req,
427        actix_web::error::ErrorTooManyRequests(message.clone()),
428        GatewayError::RateLimit {
429            message,
430            retry_after: Some(wait_seconds),
431            rpm_limit: None,
432            tpm_limit: None,
433        },
434    )
435}
436
437/// Extract request context from request
438pub fn get_request_context(req: &HttpRequest) -> Result<SharedRequestContext, actix_web::Error> {
439    if let Some(context) = req.extensions().get::<SharedRequestContext>() {
440        return Ok(Arc::clone(context));
441    }
442
443    req.extensions()
444        .get::<RequestContext>()
445        .map(|context| Arc::new(context.clone()))
446        .ok_or_else(|| actix_web::error::ErrorInternalServerError("Missing request context"))
447}
448
449fn insert_request_context(req: &mut ServiceRequest, context: RequestContext) {
450    req.extensions_mut()
451        .insert::<SharedRequestContext>(Arc::new(context));
452}
453
454/// Extract a client identifier for the brute-force lockout limiter.
455///
456/// The bucket must be keyed by network identity only. Mixing the presented
457/// credential into the key gives every guessed secret its own bucket, so an
458/// attacker rotating random credentials never accumulates failures in any
459/// one bucket and is never locked out — which defeats the limiter entirely.
460/// Requests without a transport peer return `None` so unrelated internal
461/// callers never collide in a process-wide `ip:unknown` bucket.
462fn get_client_identifier(req: &ServiceRequest, trusted_proxies: &[String]) -> Option<String> {
463    req.peer_addr()
464        .map(|_| network_client_key(req, trusted_proxies))
465}
466
467fn build_request_context(req: &mut ServiceRequest) -> RequestContext {
468    let mut context = RequestContext::new();
469
470    // Use the request ID set by RequestIdMiddleware when present; otherwise keep
471    // the UUID that RequestContext::new() already generated so that AuthMiddleware
472    // remains self-sufficient when used without RequestIdMiddleware in the stack.
473    if let Some(id) = req
474        .headers()
475        .get("x-request-id")
476        .and_then(|value| value.to_str().ok())
477        .filter(|s| !s.is_empty())
478    {
479        context.request_id = id.to_string();
480    }
481
482    context.user_agent = req
483        .headers()
484        .get("user-agent")
485        .and_then(|value| value.to_str().ok())
486        .map(str::to_string);
487    context.client_ip = req.connection_info().peer_addr().map(|ip| ip.to_string());
488
489    let mut headers = HashMap::new();
490    for (name, value) in req.headers().iter() {
491        if name.as_str().eq_ignore_ascii_case("authorization")
492            || name.as_str().eq_ignore_ascii_case("x-api-key")
493        {
494            continue;
495        }
496        if let Ok(value) = value.to_str() {
497            headers.insert(name.as_str().to_string(), value.to_string());
498        }
499    }
500    context.headers = headers;
501
502    context
503}
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508    use actix_web::test::TestRequest;
509
510    fn client_id_for_header(header_name: &'static str, header_value: &'static str) -> String {
511        let req = TestRequest::default()
512            .peer_addr("203.0.113.55:1000".parse().unwrap())
513            .insert_header((header_name, header_value))
514            .to_srv_request();
515        get_client_identifier(&req, &[]).unwrap()
516    }
517
518    #[test]
519    fn client_identifier_groups_api_key_transports() {
520        let configured = client_id_for_header("x-litellm-key", "gw-same-key");
521        let fallback = client_id_for_header("x-api-key", "gw-same-key");
522        let authorization_scheme = client_id_for_header("authorization", "ApiKey gw-same-key");
523        let authorization_raw = client_id_for_header("authorization", "gw-same-key");
524
525        assert_eq!(configured, fallback);
526        assert_eq!(configured, authorization_scheme);
527        assert_eq!(configured, authorization_raw);
528    }
529
530    #[test]
531    fn client_identifier_groups_rotated_credentials_into_one_bucket() {
532        // The previous behavior asserted distinct buckets per credential,
533        // which let an attacker rotating random secrets dodge the lockout
534        // entirely. Failures must accumulate in one per-IP bucket.
535        let first = client_id_for_header("x-api-key", "gw-first-key");
536        let second = client_id_for_header("x-api-key", "gw-second-key");
537
538        assert_eq!(first, second);
539        assert_eq!(first, "ip:203.0.113.55");
540    }
541
542    #[test]
543    fn client_identifier_groups_jwt_and_api_key_guesses_from_same_ip() {
544        let api_key = client_id_for_header("x-api-key", "gw-some-key");
545        let jwt = client_id_for_header("authorization", "Bearer some.jwt.token");
546
547        assert_eq!(api_key, jwt);
548        assert_eq!(jwt, "ip:203.0.113.55");
549    }
550
551    #[test]
552    fn client_identifier_ignores_peer_port() {
553        let req_a = TestRequest::default()
554            .peer_addr("203.0.113.60:1000".parse().unwrap())
555            .insert_header(("x-api-key", "gw-same-key"))
556            .to_srv_request();
557        let req_b = TestRequest::default()
558            .peer_addr("203.0.113.60:2000".parse().unwrap())
559            .insert_header(("x-api-key", "gw-same-key"))
560            .to_srv_request();
561        assert_eq!(
562            get_client_identifier(&req_a, &[]),
563            get_client_identifier(&req_b, &[])
564        );
565    }
566
567    #[test]
568    fn client_identifier_falls_back_to_ip_without_auth() {
569        let req = TestRequest::default()
570            .peer_addr("203.0.113.70:1000".parse().unwrap())
571            .to_srv_request();
572
573        assert_eq!(
574            get_client_identifier(&req, &[]).as_deref(),
575            Some("ip:203.0.113.70")
576        );
577    }
578
579    #[test]
580    fn client_identifier_keeps_session_failures_in_ip_bucket() {
581        let req_a = TestRequest::default()
582            .peer_addr("203.0.113.80:1000".parse().unwrap())
583            .insert_header(("cookie", "session=session-a"))
584            .to_srv_request();
585        let req_b = TestRequest::default()
586            .peer_addr("203.0.113.80:1000".parse().unwrap())
587            .insert_header(("cookie", "session=session-b"))
588            .to_srv_request();
589        assert_eq!(
590            get_client_identifier(&req_a, &[]),
591            get_client_identifier(&req_b, &[])
592        );
593        assert_eq!(
594            get_client_identifier(&req_a, &[]).as_deref(),
595            Some("ip:203.0.113.80")
596        );
597    }
598
599    #[test]
600    fn client_identifier_uses_forwarded_client_only_for_trusted_peer() {
601        let trusted = vec!["192.0.2.10".to_string()];
602        let trusted_req = TestRequest::default()
603            .peer_addr("192.0.2.10:443".parse().unwrap())
604            .insert_header(("x-forwarded-for", "198.51.100.20, 192.0.2.10"))
605            .to_srv_request();
606        let untrusted_req = TestRequest::default()
607            .peer_addr("192.0.2.11:443".parse().unwrap())
608            .insert_header(("x-forwarded-for", "198.51.100.20"))
609            .to_srv_request();
610
611        assert_eq!(
612            get_client_identifier(&trusted_req, &trusted).as_deref(),
613            Some("ip:198.51.100.20")
614        );
615        assert_eq!(
616            get_client_identifier(&untrusted_req, &trusted).as_deref(),
617            Some("ip:192.0.2.11")
618        );
619    }
620
621    #[test]
622    fn client_identifier_without_transport_peer_is_untracked() {
623        let req = TestRequest::default()
624            .insert_header(("x-forwarded-for", "198.51.100.20"))
625            .to_srv_request();
626
627        assert_eq!(get_client_identifier(&req, &[]), None);
628    }
629
630    #[test]
631    fn auth_verification_precheck_only_applies_to_present_credentials() {
632        assert!(requires_auth_verification(&AuthMethod::Jwt(
633            "jwt-token".to_string()
634        )));
635        assert!(requires_auth_verification(&AuthMethod::ApiKey(
636            "api-key".to_string()
637        )));
638        assert!(requires_auth_verification(&AuthMethod::Session(
639            "session-id".to_string()
640        )));
641        assert!(!requires_auth_verification(&AuthMethod::None));
642    }
643
644    #[test]
645    fn readiness_uses_optional_header_auth() {
646        assert!(bypasses_header_auth("/health/ready", &AuthMethod::None));
647        assert!(!bypasses_header_auth(
648            "/health/ready",
649            &AuthMethod::ApiKey("present".to_string())
650        ));
651        assert!(bypasses_header_auth(
652            "/health",
653            &AuthMethod::ApiKey("ignored".to_string())
654        ));
655    }
656
657    #[test]
658    fn insert_request_context_stores_shared_extension_handle() {
659        let api_key_id = uuid::Uuid::new_v4();
660        let mut req = TestRequest::default().to_srv_request();
661
662        insert_request_context(&mut req, RequestContext::new().with_api_key(api_key_id));
663
664        let extensions = req.extensions();
665        let stored = extensions
666            .get::<SharedRequestContext>()
667            .expect("request context should be stored as a shared handle");
668        assert_eq!(stored.api_key_id(), Some(api_key_id));
669        assert!(extensions.get::<RequestContext>().is_none());
670    }
671
672    #[test]
673    fn forbidden_response_retains_authenticated_principal_context() {
674        let user_id = uuid::Uuid::new_v4();
675        let api_key_id = uuid::Uuid::new_v4();
676        let team_id = uuid::Uuid::new_v4();
677        let mut req = TestRequest::default().to_srv_request();
678        insert_request_context(
679            &mut req,
680            RequestContext::new()
681                .with_user(user_id, Some(team_id))
682                .with_api_key(api_key_id),
683        );
684
685        let response = forbidden_response::<actix_web::body::BoxBody>(req, "denied");
686        let extensions = response.request().extensions();
687        let context = extensions
688            .get::<SharedRequestContext>()
689            .expect("authenticated context should survive a 403 response");
690
691        assert_eq!(
692            context.user_id.as_deref(),
693            Some(user_id.to_string().as_str())
694        );
695        assert_eq!(context.api_key_id(), Some(api_key_id));
696        assert_eq!(context.team_id(), Some(team_id));
697    }
698
699    #[test]
700    fn build_request_context_excludes_sensitive_auth_headers() {
701        let mut req = TestRequest::default()
702            .insert_header(("authorization", "Bearer secret"))
703            .insert_header(("x-api-key", "sk-secret"))
704            .insert_header(("x-request-id", "req-123"))
705            .insert_header(("x-observable", "kept"))
706            .to_srv_request();
707
708        let context = build_request_context(&mut req);
709
710        assert_eq!(context.request_id, "req-123");
711        assert_eq!(
712            context.headers.get("x-observable").map(String::as_str),
713            Some("kept")
714        );
715        assert!(!context.headers.contains_key("authorization"));
716        assert!(!context.headers.contains_key("x-api-key"));
717    }
718
719    #[actix_web::test]
720    async fn authentication_unavailable_response_is_generic_server_error() {
721        let req = TestRequest::with_uri("/v1/chat/completions").to_srv_request();
722        let response = authentication_unavailable_response::<actix_web::body::BoxBody>(req);
723
724        assert_eq!(
725            response.status(),
726            actix_web::http::StatusCode::INTERNAL_SERVER_ERROR
727        );
728        let body = actix_web::body::to_bytes(response.into_body())
729            .await
730            .expect("generic authentication error body should render");
731        let body: serde_json::Value = serde_json::from_slice(&body)
732            .expect("generic authentication error body should be valid JSON");
733        assert_eq!(
734            body["error"]["message"],
735            AUTHENTICATION_SERVICE_UNAVAILABLE_MESSAGE
736        );
737        let body = body.to_string();
738        for internal_detail in [
739            "Storage error",
740            "Database error",
741            "Redis error",
742            "Connection closed",
743        ] {
744            assert!(!body.contains(internal_detail));
745        }
746    }
747}