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