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 pub fn challenge_header_value(&self) -> String {
342 format!(r#"Basic realm="{}""#, self.realm)
343 }
344
345 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 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 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 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 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 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}