Skip to main content

securitydept_basic_auth_context/
lib.rs

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    /// Returns `WWW-Authenticate` header value for the configured realm.
263    pub fn challenge_header_value(&self) -> String {
264        format!(r#"Basic realm="{}""#, self.realm)
265    }
266
267    /// Returns true when request path is inside configured middleware zone.
268    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    /// Rule for whether a `WWW-Authenticate` challenge header should be
285    /// attached.
286    ///
287    /// Only emit challenge when unauthorized response comes from `login_path`.
288    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    /// Build the challenge response for login trigger route.
293    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    /// Build success redirect response for a successful `/basic/login`
303    /// authentication.
304    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    /// Build logout poisoning response.
314    ///
315    /// MUST be `401` without `WWW-Authenticate`.
316    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    /// Build unauthorized response for generic handler paths.
325    ///
326    /// - for `login_path`: 401 with challenge header.
327    /// - for all other paths: plain 401 without challenge header.
328    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}