Skip to main content

securitydept_basic_auth_context/
lib.rs

1mod service;
2
3use std::sync::Arc;
4
5use http::StatusCode;
6use securitydept_creds::{BasicAuthCred, BasicAuthCredsConfig};
7use securitydept_realip::{RealIpAccessConfig, RealIpAccessManager, RealIpError, ResolvedClientIp};
8use securitydept_utils::{
9    http::HttpResponse,
10    redirect::{RedirectTargetConfig, RedirectTargetError, UriRelativeRedirectTargetResolver},
11};
12use serde::{Deserialize, Serialize};
13pub use service::{BasicAuthContextService, BasicAuthContextServiceError};
14use snafu::Snafu;
15use typed_builder::TypedBuilder;
16use web_route::WebRoute;
17
18#[derive(Debug, Clone, Serialize, Deserialize, TypedBuilder)]
19pub struct BasicAuthZoneConfig {
20    #[builder(default = default_zone_prefix())]
21    #[serde(default = "default_zone_prefix")]
22    pub zone_prefix: String,
23    #[builder(default = default_login_subpath())]
24    #[serde(default = "default_login_subpath")]
25    pub login_subpath: String,
26    #[builder(default = default_logout_subpath())]
27    #[serde(default = "default_logout_subpath")]
28    pub logout_subpath: String,
29    #[builder(default, setter(strip_option))]
30    #[serde(default)]
31    pub realm: Option<String>,
32    #[serde(default)]
33    #[builder(default, setter(strip_option))]
34    pub post_auth_redirect: Option<RedirectTargetConfig>,
35}
36
37impl Default for BasicAuthZoneConfig {
38    fn default() -> Self {
39        Self {
40            zone_prefix: default_zone_prefix(),
41            login_subpath: default_login_subpath(),
42            logout_subpath: default_logout_subpath(),
43            realm: None,
44            post_auth_redirect: None,
45        }
46    }
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize, TypedBuilder)]
50pub struct BasicAuthContextConfig<Creds>
51where
52    Creds: BasicAuthCred + Serialize + for<'a> Deserialize<'a>,
53{
54    #[serde(
55        flatten,
56        bound = "Creds: BasicAuthCred + Serialize + for<'a> Deserialize<'a>",
57        default = "BasicAuthCredsConfig::default"
58    )]
59    #[builder(default = BasicAuthCredsConfig::default())]
60    pub creds: BasicAuthCredsConfig<Creds>,
61    #[serde(default)]
62    #[builder(default, setter(strip_option))]
63    pub real_ip_access: Option<RealIpAccessConfig>,
64    #[serde(default)]
65    #[builder(default = Vec::new())]
66    pub zones: Vec<BasicAuthZoneConfig>,
67    #[serde(default)]
68    #[builder(default, setter(strip_option))]
69    pub realm: Option<String>,
70    #[serde(default = "default_post_auth_redirect")]
71    #[builder(default = default_post_auth_redirect())]
72    pub post_auth_redirect: RedirectTargetConfig,
73}
74
75impl<Creds> Default for BasicAuthContextConfig<Creds>
76where
77    Creds: BasicAuthCred + Serialize + for<'a> Deserialize<'a>,
78{
79    fn default() -> Self {
80        Self {
81            creds: BasicAuthCredsConfig::default(),
82            post_auth_redirect: default_post_auth_redirect(),
83            real_ip_access: None,
84            zones: Vec::new(),
85            realm: None,
86        }
87    }
88}
89
90fn default_zone_prefix() -> String {
91    "/basic".to_string()
92}
93
94fn default_login_subpath() -> String {
95    "/login".to_string()
96}
97
98fn default_logout_subpath() -> String {
99    "/logout".to_string()
100}
101
102fn default_post_auth_redirect() -> RedirectTargetConfig {
103    RedirectTargetConfig::strict_default("/")
104}
105
106fn default_realm() -> String {
107    "securitydept".to_string()
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub enum BasicAuthProtocolResponseKind {
112    Challenge,
113    Unauthorized,
114    LogoutPoison,
115}
116
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct BasicAuthProtocolResponse {
119    kind: BasicAuthProtocolResponseKind,
120    status: StatusCode,
121    challenge_header: Option<String>,
122}
123
124impl BasicAuthProtocolResponse {
125    pub fn challenge(challenge_header: String) -> Self {
126        Self {
127            kind: BasicAuthProtocolResponseKind::Challenge,
128            status: StatusCode::UNAUTHORIZED,
129            challenge_header: Some(challenge_header),
130        }
131    }
132
133    pub fn unauthorized() -> Self {
134        Self {
135            kind: BasicAuthProtocolResponseKind::Unauthorized,
136            status: StatusCode::UNAUTHORIZED,
137            challenge_header: None,
138        }
139    }
140
141    pub fn logout_poison() -> Self {
142        Self {
143            kind: BasicAuthProtocolResponseKind::LogoutPoison,
144            status: StatusCode::UNAUTHORIZED,
145            challenge_header: None,
146        }
147    }
148
149    pub fn kind(&self) -> BasicAuthProtocolResponseKind {
150        self.kind
151    }
152
153    pub fn status(&self) -> StatusCode {
154        self.status
155    }
156
157    pub fn challenge_header(&self) -> Option<&str> {
158        self.challenge_header.as_deref()
159    }
160
161    pub fn into_http_response(self) -> HttpResponse {
162        match self.challenge_header {
163            Some(header) => HttpResponse::unauthorized_with_basic_challenge(&header),
164            None => HttpResponse::new(self.status),
165        }
166    }
167}
168
169#[derive(Debug, Clone)]
170pub struct BasicAuthZone {
171    pub zone_prefix: WebRoute,
172    pub login_path: WebRoute,
173    pub logout_path: WebRoute,
174    pub post_auth_redirect: Arc<RedirectTargetConfig>,
175    pub realm: String,
176    post_auth_redirect_resolver: Arc<UriRelativeRedirectTargetResolver>,
177}
178
179#[derive(Debug, Clone)]
180pub struct BasicAuthContext<Creds>
181where
182    Creds: BasicAuthCred + Serialize + for<'a> Deserialize<'a>,
183{
184    pub creds: Arc<BasicAuthCredsConfig<Creds>>,
185    pub zones: Vec<BasicAuthZone>,
186    pub realm: String,
187    pub post_auth_redirect: Arc<RedirectTargetConfig>,
188    pub real_ip_access: Option<Arc<RealIpAccessConfig>>,
189    post_auth_redirect_resolver: Arc<UriRelativeRedirectTargetResolver>,
190    real_ip_access_manager: Option<Arc<RealIpAccessManager>>,
191}
192
193#[derive(Debug, Snafu)]
194pub enum BasicAuthContextError {
195    #[snafu(display("post-auth redirect is invalid: {source}"))]
196    RedirectTarget { source: RedirectTargetError },
197    #[snafu(display("real-ip access is invalid: {source}"))]
198    RealIp { source: RealIpError },
199}
200
201pub type BasicAuthContextResult<T> = Result<T, BasicAuthContextError>;
202
203impl<Creds> BasicAuthContextConfig<Creds>
204where
205    Creds: BasicAuthCred + Serialize + for<'a> Deserialize<'a>,
206{
207    pub fn validate(&self) -> BasicAuthContextResult<()> {
208        BasicAuthContext::from_config(self.clone()).map(|_| ())
209    }
210
211    pub fn ensure_real_ip_allowed(
212        &self,
213        resolved_client_ip: &ResolvedClientIp,
214    ) -> BasicAuthContextResult<()> {
215        BasicAuthContext::from_config(self.clone())?.ensure_real_ip_allowed(resolved_client_ip)
216    }
217}
218
219impl<Creds> BasicAuthContext<Creds>
220where
221    Creds: BasicAuthCred + Serialize + for<'a> Deserialize<'a>,
222{
223    pub fn from_config(config: BasicAuthContextConfig<Creds>) -> BasicAuthContextResult<Self> {
224        let post_auth_redirect_resolver = Arc::new(
225            UriRelativeRedirectTargetResolver::from_config(config.post_auth_redirect.clone())
226                .map_err(|source| BasicAuthContextError::RedirectTarget { source })?,
227        );
228        let real_ip_access_manager = config
229            .real_ip_access
230            .clone()
231            .map(RealIpAccessManager::from_config)
232            .transpose()
233            .map_err(|source| BasicAuthContextError::RealIp { source })?
234            .map(Arc::new);
235        let post_auth_redirect = Arc::new(config.post_auth_redirect.clone());
236        let realm = config.realm.unwrap_or_else(default_realm);
237
238        let mut context = Self {
239            creds: Arc::new(config.creds),
240            zones: Vec::with_capacity(config.zones.len()),
241            realm,
242            post_auth_redirect,
243            real_ip_access: config.real_ip_access.map(Arc::new),
244            post_auth_redirect_resolver,
245            real_ip_access_manager,
246        };
247
248        context.zones = config
249            .zones
250            .into_iter()
251            .map(|zone| BasicAuthZone::from_context_config(zone, &context))
252            .collect::<BasicAuthContextResult<Vec<_>>>()?;
253
254        Ok(context)
255    }
256
257    pub fn ensure_real_ip_allowed(
258        &self,
259        resolved_client_ip: &ResolvedClientIp,
260    ) -> BasicAuthContextResult<()> {
261        if let Some(real_ip_access_manager) = &self.real_ip_access_manager {
262            real_ip_access_manager
263                .ensure_allowed(resolved_client_ip)
264                .map_err(|source| BasicAuthContextError::RealIp { source })?;
265        }
266
267        Ok(())
268    }
269
270    pub fn resolve_post_auth_redirect_uri(
271        &self,
272        requested_post_auth_redirect_uri: Option<&str>,
273    ) -> BasicAuthContextResult<WebRoute> {
274        let redirect_target = self
275            .post_auth_redirect_resolver
276            .resolve_redirect_target(requested_post_auth_redirect_uri)
277            .map_err(|source| BasicAuthContextError::RedirectTarget { source })?;
278
279        Ok(resolve_root_web_route(redirect_target.as_str()))
280    }
281
282    pub fn zone_for_request_path(&self, request_path: &str) -> Option<&BasicAuthZone> {
283        self.zones
284            .iter()
285            .find(|zone| zone.is_zone_path(request_path))
286    }
287}
288
289impl BasicAuthZone {
290    pub fn from_isolated_config(config: BasicAuthZoneConfig) -> BasicAuthContextResult<Self> {
291        let post_auth_redirect = config
292            .post_auth_redirect
293            .unwrap_or_else(default_post_auth_redirect);
294        let post_auth_redirect_resolver = Arc::new(
295            UriRelativeRedirectTargetResolver::from_config(post_auth_redirect.clone())
296                .map_err(|source| BasicAuthContextError::RedirectTarget { source })?,
297        );
298
299        let zone_prefix = WebRoute::new(config.zone_prefix);
300
301        Ok(Self {
302            zone_prefix: zone_prefix.clone(),
303            login_path: zone_prefix.join(config.login_subpath),
304            logout_path: zone_prefix.join(config.logout_subpath),
305            post_auth_redirect: Arc::new(post_auth_redirect),
306            realm: config.realm.unwrap_or_else(default_realm),
307            post_auth_redirect_resolver,
308        })
309    }
310
311    pub fn from_context_config(
312        config: BasicAuthZoneConfig,
313        context: &BasicAuthContext<impl BasicAuthCred + Serialize + for<'a> Deserialize<'a>>,
314    ) -> BasicAuthContextResult<Self> {
315        let post_auth_redirect_resolver = config
316            .post_auth_redirect
317            .as_ref()
318            .map(|par| UriRelativeRedirectTargetResolver::from_config(par.clone()))
319            .transpose()
320            .map_err(|source| BasicAuthContextError::RedirectTarget { source })?
321            .map(Arc::new)
322            .unwrap_or_else(|| context.post_auth_redirect_resolver.clone());
323        let post_auth_redirect = config
324            .post_auth_redirect
325            .map(Arc::new)
326            .unwrap_or_else(|| context.post_auth_redirect.clone());
327
328        let zone_prefix = WebRoute::new(config.zone_prefix);
329
330        Ok(Self {
331            zone_prefix: zone_prefix.clone(),
332            login_path: zone_prefix.join(config.login_subpath),
333            logout_path: zone_prefix.join(config.logout_subpath),
334            post_auth_redirect,
335            realm: config.realm.unwrap_or_else(|| context.realm.clone()),
336            post_auth_redirect_resolver,
337        })
338    }
339
340    /// Returns `WWW-Authenticate` header value for the configured realm.
341    pub fn challenge_header_value(&self) -> String {
342        format!(r#"Basic realm="{}""#, self.realm)
343    }
344
345    /// Returns true when request path is inside configured middleware zone.
346    pub fn is_zone_path(&self, request_path: &str) -> bool {
347        let zone_prefix = &self.zone_prefix as &str;
348        request_path == zone_prefix
349            || request_path
350                .strip_prefix(zone_prefix)
351                .is_some_and(|suffix| suffix.starts_with('/'))
352    }
353
354    pub fn is_login_path(&self, request_path: &str) -> bool {
355        request_path == &self.login_path as &str
356    }
357
358    pub fn is_logout_path(&self, request_path: &str) -> bool {
359        request_path == &self.logout_path as &str
360    }
361
362    /// Rule for whether a `WWW-Authenticate` challenge header should be
363    /// attached.
364    ///
365    /// Only emit challenge when unauthorized response comes from `login_path`.
366    pub fn should_attach_challenge_header(&self, request_path: &str, status: StatusCode) -> bool {
367        status == StatusCode::UNAUTHORIZED && self.is_login_path(request_path)
368    }
369
370    /// Build the challenge response for login trigger route.
371    pub fn login_challenge_protocol_response(&self) -> BasicAuthProtocolResponse {
372        BasicAuthProtocolResponse::challenge(self.challenge_header_value())
373    }
374
375    pub fn login_challenge_response(&self) -> HttpResponse {
376        self.login_challenge_protocol_response()
377            .into_http_response()
378    }
379
380    /// Build success redirect response for a successful `/basic/login`
381    /// authentication.
382    pub fn login_success_response(
383        &self,
384        requested_post_auth_redirect_uri: Option<&str>,
385    ) -> Result<HttpResponse, BasicAuthContextError> {
386        let redirect_target =
387            self.resolve_post_auth_redirect_uri(requested_post_auth_redirect_uri)?;
388        Ok(HttpResponse::found(&redirect_target))
389    }
390
391    /// Build logout poisoning response.
392    ///
393    /// MUST be `401` without `WWW-Authenticate`.
394    pub fn logout_poison_protocol_response(&self) -> BasicAuthProtocolResponse {
395        BasicAuthProtocolResponse::logout_poison()
396    }
397
398    pub fn logout_poison_response(&self) -> HttpResponse {
399        self.logout_poison_protocol_response().into_http_response()
400    }
401
402    /// Build unauthorized response for generic handler paths.
403    ///
404    /// - for `login_path`: 401 with challenge header.
405    /// - for all other paths: plain 401 without challenge header.
406    pub fn unauthorized_protocol_response_for_path(
407        &self,
408        request_path: &str,
409    ) -> BasicAuthProtocolResponse {
410        if self.is_login_path(request_path) {
411            self.login_challenge_protocol_response()
412        } else {
413            BasicAuthProtocolResponse::unauthorized()
414        }
415    }
416
417    pub fn unauthorized_response_for_path(&self, request_path: &str) -> HttpResponse {
418        self.unauthorized_protocol_response_for_path(request_path)
419            .into_http_response()
420    }
421
422    pub fn resolve_post_auth_redirect_uri(
423        &self,
424        requested_post_auth_redirect_uri: Option<&str>,
425    ) -> Result<WebRoute, BasicAuthContextError> {
426        let redirect_target = self
427            .post_auth_redirect_resolver
428            .resolve_redirect_target(requested_post_auth_redirect_uri)
429            .map_err(|source| BasicAuthContextError::RedirectTarget { source })?;
430
431        Ok(resolve_web_route(
432            &self.zone_prefix,
433            redirect_target.as_str(),
434        ))
435    }
436}
437
438fn resolve_web_route(zone_prefix: &WebRoute, redirect_target: &str) -> WebRoute {
439    if redirect_target.starts_with('/') {
440        WebRoute::new(redirect_target)
441    } else {
442        zone_prefix.join(redirect_target)
443    }
444}
445
446fn resolve_root_web_route(redirect_target: &str) -> WebRoute {
447    if redirect_target.starts_with('/') {
448        WebRoute::new(redirect_target)
449    } else {
450        WebRoute::new(format!("/{redirect_target}"))
451    }
452}
453
454#[cfg(test)]
455mod tests {
456    use std::net::{IpAddr, Ipv4Addr};
457
458    use securitydept_realip::ResolvedSourceKind;
459
460    use super::*;
461
462    #[test]
463    fn test_default_zone_paths() {
464        let zone = BasicAuthZoneConfig::default();
465
466        assert_eq!(zone.zone_prefix, "/basic");
467        assert_eq!(zone.login_subpath, "/login");
468        assert_eq!(zone.logout_subpath, "/logout");
469
470        assert_eq!(zone.realm, None);
471        assert_eq!(zone.post_auth_redirect, None);
472    }
473
474    #[test]
475    fn test_customizable_paths() {
476        let zone_config = BasicAuthZoneConfig::builder()
477            .zone_prefix("/internal/basic/".to_string())
478            .login_subpath("/signin".to_string())
479            .logout_subpath("signout".to_string())
480            .post_auth_redirect(RedirectTargetConfig::strict_default("app"))
481            .realm("corp".to_string())
482            .build();
483        let zone = BasicAuthZone::from_isolated_config(zone_config).expect("zone should build");
484
485        assert_eq!(&zone.zone_prefix as &str, "/internal/basic");
486        assert_eq!(&zone.login_path as &str, "/internal/basic/signin");
487        assert_eq!(&zone.logout_path as &str, "/internal/basic/signout");
488        assert_eq!(
489            &zone
490                .resolve_post_auth_redirect_uri(None)
491                .expect("redirect should resolve") as &str,
492            "/internal/basic/app"
493        );
494        assert_eq!(zone.challenge_header_value(), r#"Basic realm="corp""#);
495    }
496
497    #[test]
498    fn test_dynamic_post_auth_redirect_is_allowed() {
499        let zone = BasicAuthZone::from_isolated_config(
500            BasicAuthZoneConfig::builder()
501                .post_auth_redirect(RedirectTargetConfig::dynamic_default_and_dynamic_targets(
502                    "/",
503                    [securitydept_utils::redirect::RedirectTargetRule::Strict {
504                        value: "/app".to_string(),
505                    }],
506                ))
507                .build(),
508        )
509        .expect("zone should build");
510
511        assert_eq!(
512            &zone
513                .resolve_post_auth_redirect_uri(Some("/app"))
514                .expect("redirect should resolve") as &str,
515            "/app"
516        );
517    }
518
519    #[test]
520    fn test_zone_path_match() {
521        let zone = BasicAuthZone::from_isolated_config(BasicAuthZoneConfig::default())
522            .expect("zone should build");
523
524        assert!(zone.is_zone_path("/basic"));
525        assert!(zone.is_zone_path("/basic/login"));
526        assert!(zone.is_zone_path("/basic/any/sub"));
527        assert!(!zone.is_zone_path("/api/v1/me"));
528        assert!(!zone.is_zone_path("/basically"));
529    }
530
531    #[test]
532    fn test_should_attach_challenge_header() {
533        let zone = BasicAuthZone::from_isolated_config(BasicAuthZoneConfig::default())
534            .expect("zone should build");
535
536        assert!(zone.should_attach_challenge_header("/basic/login", StatusCode::UNAUTHORIZED));
537        assert!(!zone.should_attach_challenge_header("/api/v1/me", StatusCode::UNAUTHORIZED));
538        assert!(!zone.should_attach_challenge_header("/basic/login", StatusCode::FORBIDDEN));
539    }
540
541    #[test]
542    fn test_login_challenge_protocol_response_preserves_www_authenticate() {
543        let zone = BasicAuthZone::from_isolated_config(BasicAuthZoneConfig::default())
544            .expect("zone should build");
545
546        let response = zone.login_challenge_protocol_response();
547
548        assert_eq!(response.kind(), BasicAuthProtocolResponseKind::Challenge);
549        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
550        assert_eq!(
551            response.challenge_header(),
552            Some(r#"Basic realm="securitydept""#),
553        );
554    }
555
556    #[test]
557    fn test_logout_poison_protocol_response_omits_www_authenticate() {
558        let zone = BasicAuthZone::from_isolated_config(BasicAuthZoneConfig::default())
559            .expect("zone should build");
560
561        let response = zone.logout_poison_protocol_response();
562
563        assert_eq!(response.kind(), BasicAuthProtocolResponseKind::LogoutPoison);
564        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
565        assert_eq!(response.challenge_header(), None);
566    }
567
568    #[test]
569    fn test_unauthorized_protocol_response_for_non_login_path_is_plain_401() {
570        let zone = BasicAuthZone::from_isolated_config(BasicAuthZoneConfig::default())
571            .expect("zone should build");
572
573        let response = zone.unauthorized_protocol_response_for_path("/basic/api/entries");
574
575        assert_eq!(response.kind(), BasicAuthProtocolResponseKind::Unauthorized);
576        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
577        assert_eq!(response.challenge_header(), None);
578    }
579
580    #[derive(Debug, Clone, Serialize, Deserialize, Default)]
581    struct TestCred {
582        username: String,
583        password_hash: String,
584    }
585
586    impl BasicAuthCred for TestCred {
587        fn username(&self) -> &str {
588            &self.username
589        }
590
591        fn verify_password(&self, password: &str) -> securitydept_creds::CredsResult<bool> {
592            Ok(password == self.password_hash)
593        }
594    }
595
596    #[test]
597    fn test_basic_auth_context_rejects_invalid_real_ip_access_config() {
598        let error = BasicAuthContextConfig::<TestCred>::builder()
599            .real_ip_access(RealIpAccessConfig::default())
600            .build()
601            .validate()
602            .expect_err("empty real-ip access config should be rejected");
603
604        assert!(matches!(error, BasicAuthContextError::RealIp { .. }));
605    }
606
607    #[test]
608    fn test_basic_auth_context_allows_matching_real_ip() {
609        let config = BasicAuthContextConfig::<TestCred>::builder()
610            .real_ip_access(RealIpAccessConfig {
611                allowed_cidrs: vec!["10.0.0.0/8".parse().expect("cidr should parse")],
612                allow_fallback: false,
613            })
614            .build();
615
616        let resolved = ResolvedClientIp {
617            client_ip: IpAddr::V4(Ipv4Addr::new(10, 1, 2, 3)),
618            peer_ip: IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10)),
619            source_name: Some("proxy".to_string()),
620            source_kind: ResolvedSourceKind::Header,
621            header_name: Some("x-forwarded-for".to_string()),
622        };
623
624        config
625            .ensure_real_ip_allowed(&resolved)
626            .expect("resolved client IP should be allowed");
627    }
628
629    #[test]
630    fn test_basic_auth_context_builds_zones_with_global_defaults() {
631        let context = BasicAuthContextConfig::<TestCred>::builder()
632            .realm("corp".to_string())
633            .post_auth_redirect(RedirectTargetConfig::strict_default("/console"))
634            .zones(vec![
635                BasicAuthZoneConfig::builder()
636                    .zone_prefix("/internal/basic".to_string())
637                    .build(),
638            ])
639            .build();
640        let context = BasicAuthContext::from_config(context).expect("context should build");
641
642        assert_eq!(context.realm, "corp");
643        assert_eq!(context.zones.len(), 1);
644        assert_eq!(context.zones[0].realm, "corp");
645        assert_eq!(
646            &context.zones[0]
647                .resolve_post_auth_redirect_uri(None)
648                .expect("zone redirect should resolve") as &str,
649            "/console"
650        );
651    }
652
653    #[test]
654    fn test_basic_auth_context_finds_zone_for_request_path() {
655        let context = BasicAuthContext::from_config(
656            BasicAuthContextConfig::<TestCred>::builder()
657                .zones(vec![
658                    BasicAuthZoneConfig::builder()
659                        .zone_prefix("/internal/basic".to_string())
660                        .build(),
661                ])
662                .build(),
663        )
664        .expect("context should build");
665
666        assert!(context.zone_for_request_path("/internal/basic").is_some());
667        assert!(
668            context
669                .zone_for_request_path("/internal/basic/login")
670                .is_some()
671        );
672        assert!(context.zone_for_request_path("/api").is_none());
673    }
674}