Skip to main content

securitydept_basic_auth_context/
service.rs

1use http::StatusCode;
2use securitydept_creds::{
3    BasicAuthCred, BasicAuthCredsValidator, CredsError, MapBasicAuthCredsValidator,
4    parse_basic_auth_header_opt,
5};
6use securitydept_realip::ResolvedClientIp;
7use securitydept_utils::{
8    error::{ErrorPresentation, ToErrorPresentation, UserRecovery},
9    http::{HttpResponse, ToHttpStatus},
10    observability::{
11        AuthFlowDiagnosis, AuthFlowDiagnosisField, AuthFlowDiagnosisOutcome, AuthFlowOperation,
12        DiagnosedResult,
13    },
14};
15use serde::{Deserialize, Serialize};
16use snafu::Snafu;
17
18use crate::{BasicAuthContext, BasicAuthContextError, BasicAuthZone};
19
20fn basic_auth_diagnosis(operation: &str) -> AuthFlowDiagnosis {
21    AuthFlowDiagnosis::started(operation).field(AuthFlowDiagnosisField::AUTH_FAMILY, "basic-auth")
22}
23
24/// Errors produced by [`BasicAuthContextService`] operations.
25#[derive(Debug, Snafu)]
26pub enum BasicAuthContextServiceError {
27    #[snafu(transparent)]
28    BasicAuthContext { source: BasicAuthContextError },
29    #[snafu(transparent)]
30    Creds { source: CredsError },
31}
32
33impl ToHttpStatus for BasicAuthContextServiceError {
34    fn to_http_status(&self) -> StatusCode {
35        match self {
36            Self::BasicAuthContext { .. } => StatusCode::INTERNAL_SERVER_ERROR,
37            Self::Creds { source } => source.to_http_status(),
38        }
39    }
40}
41
42impl ToErrorPresentation for BasicAuthContextServiceError {
43    fn to_error_presentation(&self) -> ErrorPresentation {
44        match self {
45            Self::BasicAuthContext { .. } => ErrorPresentation::new(
46                "basic_auth_context_invalid",
47                "Basic-auth context is misconfigured.",
48                UserRecovery::ContactSupport,
49            ),
50            Self::Creds { source } => source.to_error_presentation(),
51        }
52    }
53}
54
55/// Route-facing service for basic-auth context operations.
56///
57/// Provides login, logout, and request authorization based on HTTP Basic
58/// credentials.
59pub struct BasicAuthContextService<'a, Creds>
60where
61    Creds: BasicAuthCred + Serialize + for<'de> Deserialize<'de>,
62{
63    basic_auth_context: &'a BasicAuthContext<Creds>,
64}
65
66impl<'a, Creds> BasicAuthContextService<'a, Creds>
67where
68    Creds: BasicAuthCred + Serialize + for<'de> Deserialize<'de>,
69{
70    pub fn new(
71        basic_auth_context: &'a BasicAuthContext<Creds>,
72    ) -> Result<Self, BasicAuthContextServiceError> {
73        MapBasicAuthCredsValidator::from_config(&basic_auth_context.creds)
74            .map_err(|source| BasicAuthContextServiceError::Creds { source })?;
75
76        Ok(Self { basic_auth_context })
77    }
78
79    pub fn context(&self) -> &BasicAuthContext<Creds> {
80        self.basic_auth_context
81    }
82
83    pub fn login(
84        &self,
85        request_path: &str,
86        authorization_header: Option<&str>,
87        requested_post_auth_redirect_uri: Option<&str>,
88        resolved_client_ip: Option<&ResolvedClientIp>,
89    ) -> Result<HttpResponse, BasicAuthContextServiceError> {
90        self.login_diagnosed(
91            request_path,
92            authorization_header,
93            requested_post_auth_redirect_uri,
94            resolved_client_ip,
95        )
96        .into_result()
97    }
98
99    pub fn login_diagnosed(
100        &self,
101        request_path: &str,
102        authorization_header: Option<&str>,
103        requested_post_auth_redirect_uri: Option<&str>,
104        resolved_client_ip: Option<&ResolvedClientIp>,
105    ) -> DiagnosedResult<HttpResponse, BasicAuthContextServiceError> {
106        let diagnosis = basic_auth_diagnosis(AuthFlowOperation::BASIC_AUTH_LOGIN)
107            .field(AuthFlowDiagnosisField::REQUEST_PATH, request_path)
108            .field("authorization_present", authorization_header.is_some())
109            .field(
110                "has_requested_post_auth_redirect_uri",
111                requested_post_auth_redirect_uri.is_some(),
112            )
113            .field(
114                AuthFlowDiagnosisField::RESOLVED_CLIENT_IP_PRESENT,
115                resolved_client_ip.is_some(),
116            );
117        let Some(zone) = self.basic_auth_context.zone_for_request_path(request_path) else {
118            return DiagnosedResult::success(
119                diagnosis
120                    .with_outcome(AuthFlowDiagnosisOutcome::Rejected)
121                    .field(AuthFlowDiagnosisField::REASON, "zone_not_found")
122                    .field(
123                        AuthFlowDiagnosisField::HTTP_STATUS,
124                        StatusCode::NOT_FOUND.as_u16(),
125                    ),
126                HttpResponse::new(StatusCode::NOT_FOUND),
127            );
128        };
129
130        let real_ip_allowed = match self.real_ip_allowed(resolved_client_ip) {
131            Ok(allowed) => allowed,
132            Err(source) => {
133                return DiagnosedResult::failure(
134                    diagnosis
135                        .clone()
136                        .with_outcome(AuthFlowDiagnosisOutcome::Failed)
137                        .field(AuthFlowDiagnosisField::REASON, "real_ip_resolution_failed"),
138                    source,
139                );
140            }
141        };
142        if !real_ip_allowed {
143            return DiagnosedResult::success(
144                diagnosis
145                    .with_outcome(AuthFlowDiagnosisOutcome::Rejected)
146                    .field(AuthFlowDiagnosisField::REASON, "real_ip_forbidden")
147                    .field(
148                        AuthFlowDiagnosisField::HTTP_STATUS,
149                        StatusCode::FORBIDDEN.as_u16(),
150                    ),
151                HttpResponse::new(StatusCode::FORBIDDEN),
152            );
153        }
154
155        let authenticated = match self.verify_basic_auth(authorization_header) {
156            Ok(authenticated) => authenticated,
157            Err(source) => {
158                return DiagnosedResult::failure(
159                    diagnosis
160                        .clone()
161                        .with_outcome(AuthFlowDiagnosisOutcome::Failed)
162                        .field(
163                            AuthFlowDiagnosisField::REASON,
164                            "credential_validation_failed",
165                        ),
166                    source,
167                );
168            }
169        };
170
171        if authenticated {
172            match zone.login_success_response(requested_post_auth_redirect_uri) {
173                Ok(response) => DiagnosedResult::success(
174                    diagnosis
175                        .with_outcome(AuthFlowDiagnosisOutcome::Succeeded)
176                        .field("authenticated", true)
177                        .field(
178                            AuthFlowDiagnosisField::HTTP_STATUS,
179                            response.status.as_u16(),
180                        ),
181                    response,
182                ),
183                Err(source) => DiagnosedResult::failure(
184                    diagnosis
185                        .with_outcome(AuthFlowDiagnosisOutcome::Failed)
186                        .field("authenticated", true)
187                        .field(AuthFlowDiagnosisField::REASON, "post_auth_redirect_invalid"),
188                    BasicAuthContextServiceError::BasicAuthContext { source },
189                ),
190            }
191        } else {
192            let response = zone.login_challenge_response();
193            DiagnosedResult::success(
194                diagnosis
195                    .with_outcome(AuthFlowDiagnosisOutcome::Rejected)
196                    .field("authenticated", false)
197                    .field(AuthFlowDiagnosisField::REASON, "challenge_required")
198                    .field(
199                        AuthFlowDiagnosisField::HTTP_STATUS,
200                        response.status.as_u16(),
201                    ),
202                response,
203            )
204        }
205    }
206
207    pub fn logout(&self, request_path: &str) -> HttpResponse {
208        self.logout_diagnosed(request_path)
209            .into_result()
210            .expect("basic-auth logout diagnosis should not fail")
211    }
212
213    pub fn logout_diagnosed(
214        &self,
215        request_path: &str,
216    ) -> DiagnosedResult<HttpResponse, BasicAuthContextServiceError> {
217        let diagnosis = basic_auth_diagnosis(AuthFlowOperation::BASIC_AUTH_LOGOUT)
218            .field(AuthFlowDiagnosisField::REQUEST_PATH, request_path);
219        if let Some(protocol_response) = self.logout_protocol_response(request_path) {
220            let response = protocol_response.into_http_response();
221            DiagnosedResult::success(
222                diagnosis
223                    .with_outcome(AuthFlowDiagnosisOutcome::Succeeded)
224                    .field("response_kind", "logout_poison")
225                    .field(
226                        AuthFlowDiagnosisField::HTTP_STATUS,
227                        response.status.as_u16(),
228                    ),
229                response,
230            )
231        } else {
232            DiagnosedResult::success(
233                diagnosis
234                    .with_outcome(AuthFlowDiagnosisOutcome::Rejected)
235                    .field("response_kind", "not_found")
236                    .field(
237                        AuthFlowDiagnosisField::HTTP_STATUS,
238                        StatusCode::NOT_FOUND.as_u16(),
239                    ),
240                HttpResponse::new(StatusCode::NOT_FOUND),
241            )
242        }
243    }
244
245    pub fn logout_protocol_response(
246        &self,
247        request_path: &str,
248    ) -> Option<crate::BasicAuthProtocolResponse> {
249        self.basic_auth_context
250            .zone_for_request_path(request_path)
251            .map(BasicAuthZone::logout_poison_protocol_response)
252    }
253
254    pub fn authorize_request(
255        &self,
256        authorization_header: Option<&str>,
257        resolved_client_ip: Option<&ResolvedClientIp>,
258    ) -> Result<bool, BasicAuthContextServiceError> {
259        self.authorize_request_diagnosed(authorization_header, resolved_client_ip)
260            .into_result()
261    }
262
263    pub fn authorize_request_diagnosed(
264        &self,
265        authorization_header: Option<&str>,
266        resolved_client_ip: Option<&ResolvedClientIp>,
267    ) -> DiagnosedResult<bool, BasicAuthContextServiceError> {
268        let diagnosis = basic_auth_diagnosis(AuthFlowOperation::BASIC_AUTH_AUTHORIZE)
269            .field("authorization_present", authorization_header.is_some())
270            .field(
271                AuthFlowDiagnosisField::RESOLVED_CLIENT_IP_PRESENT,
272                resolved_client_ip.is_some(),
273            );
274
275        let real_ip_allowed = match self.real_ip_allowed(resolved_client_ip) {
276            Ok(allowed) => allowed,
277            Err(source) => {
278                return DiagnosedResult::failure(
279                    diagnosis
280                        .clone()
281                        .with_outcome(AuthFlowDiagnosisOutcome::Failed)
282                        .field(AuthFlowDiagnosisField::REASON, "real_ip_resolution_failed"),
283                    source,
284                );
285            }
286        };
287        if !real_ip_allowed {
288            return DiagnosedResult::success(
289                diagnosis
290                    .with_outcome(AuthFlowDiagnosisOutcome::Rejected)
291                    .field("authorized", false)
292                    .field(AuthFlowDiagnosisField::REASON, "real_ip_forbidden"),
293                false,
294            );
295        }
296
297        match self.verify_basic_auth(authorization_header) {
298            Ok(authorized) => {
299                let outcome = if authorized {
300                    AuthFlowDiagnosisOutcome::Succeeded
301                } else {
302                    AuthFlowDiagnosisOutcome::Rejected
303                };
304                let reason = if authorized {
305                    "credentials_verified"
306                } else {
307                    "credentials_missing_or_invalid"
308                };
309                DiagnosedResult::success(
310                    diagnosis
311                        .with_outcome(outcome)
312                        .field("authorized", authorized)
313                        .field(AuthFlowDiagnosisField::REASON, reason),
314                    authorized,
315                )
316            }
317            Err(source) => DiagnosedResult::failure(
318                diagnosis
319                    .with_outcome(AuthFlowDiagnosisOutcome::Failed)
320                    .field(
321                        AuthFlowDiagnosisField::REASON,
322                        "credential_validation_failed",
323                    ),
324                source,
325            ),
326        }
327    }
328
329    fn verify_basic_auth(
330        &self,
331        authorization_header: Option<&str>,
332    ) -> Result<bool, BasicAuthContextServiceError> {
333        let Some(authorization_header) = authorization_header else {
334            return Ok(false);
335        };
336        let Some((username, password)) = parse_basic_auth_header_opt(authorization_header) else {
337            return Ok(false);
338        };
339        let validator = MapBasicAuthCredsValidator::from_config(&self.basic_auth_context.creds)
340            .map_err(|source| BasicAuthContextServiceError::Creds { source })?;
341
342        validator
343            .verify_cred(&username, &password)
344            .map(|result| result.is_some())
345            .map_err(|source| BasicAuthContextServiceError::Creds { source })
346    }
347
348    fn real_ip_allowed(
349        &self,
350        resolved_client_ip: Option<&ResolvedClientIp>,
351    ) -> Result<bool, BasicAuthContextServiceError> {
352        match (
353            self.basic_auth_context.real_ip_access.is_some(),
354            resolved_client_ip,
355        ) {
356            (false, _) => Ok(true),
357            (true, None) => Ok(false),
358            (true, Some(resolved_client_ip)) => self
359                .basic_auth_context
360                .ensure_real_ip_allowed(resolved_client_ip)
361                .map(|_| true)
362                .or_else(|error| match error {
363                    BasicAuthContextError::RealIp { .. } => Ok(false),
364                    source => Err(BasicAuthContextServiceError::BasicAuthContext { source }),
365                }),
366        }
367    }
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373    use crate::{
374        BasicAuthContext, BasicAuthContextConfig, BasicAuthContextConfigSource, BasicAuthZoneConfig,
375    };
376
377    #[derive(Debug, Clone, Serialize, Deserialize, Default)]
378    struct TestCred {
379        username: String,
380        password: String,
381    }
382
383    impl BasicAuthCred for TestCred {
384        fn username(&self) -> &str {
385            &self.username
386        }
387
388        fn verify_password(&self, password: &str) -> securitydept_creds::CredsResult<bool> {
389            Ok(password == self.password)
390        }
391    }
392
393    fn test_context() -> BasicAuthContext<TestCred> {
394        let config = BasicAuthContextConfig::builder()
395            .creds(securitydept_creds::BasicAuthCredsConfig {
396                users: vec![TestCred {
397                    username: "admin".to_string(),
398                    password: "secret".to_string(),
399                }],
400            })
401            .zones(vec![BasicAuthZoneConfig::default()])
402            .build();
403
404        BasicAuthContext::from_resolved_config(
405            BasicAuthContextConfigSource::resolve_all(&config)
406                .expect("basic auth config should resolve"),
407        )
408        .expect("context should build")
409    }
410
411    fn test_context_with_dynamic_redirect() -> BasicAuthContext<TestCred> {
412        let config = BasicAuthContextConfig::builder()
413            .creds(securitydept_creds::BasicAuthCredsConfig {
414                users: vec![TestCred {
415                    username: "admin".to_string(),
416                    password: "secret".to_string(),
417                }],
418            })
419            .zones(vec![BasicAuthZoneConfig::builder()
420                .post_auth_redirect(
421                    securitydept_utils::redirect::RedirectTargetConfig::dynamic_default_and_dynamic_targets(
422                        "/",
423                        [securitydept_utils::redirect::RedirectTargetRule::Strict {
424                            value: "/console".to_string(),
425                        }],
426                    ),
427                )
428                .build()])
429            .build();
430
431        BasicAuthContext::from_resolved_config(
432            BasicAuthContextConfigSource::resolve_all(&config)
433                .expect("basic auth config should resolve"),
434        )
435        .expect("context should build")
436    }
437
438    #[test]
439    fn login_without_credentials_returns_challenge() {
440        let context = test_context();
441        let service = BasicAuthContextService::new(&context).expect("service should build");
442        let diagnosed = service.login_diagnosed("/basic/login", None, None, None);
443        let response = diagnosed
444            .result()
445            .as_ref()
446            .expect("login should return response");
447
448        assert_eq!(response.status, StatusCode::UNAUTHORIZED);
449        assert_eq!(
450            diagnosed.diagnosis().operation,
451            AuthFlowOperation::BASIC_AUTH_LOGIN
452        );
453        assert_eq!(
454            diagnosed.diagnosis().outcome,
455            AuthFlowDiagnosisOutcome::Rejected
456        );
457        assert_eq!(diagnosed.diagnosis().fields["reason"], "challenge_required");
458    }
459
460    #[test]
461    fn login_with_valid_credentials_redirects() {
462        let context = test_context_with_dynamic_redirect();
463        let service = BasicAuthContextService::new(&context).expect("service should build");
464        let diagnosed = service.login_diagnosed(
465            "/basic/login",
466            Some("Basic YWRtaW46c2VjcmV0"),
467            Some("/console"),
468            None,
469        );
470        let response = diagnosed
471            .result()
472            .as_ref()
473            .expect("login should return response");
474
475        assert_eq!(response.status, StatusCode::FOUND);
476        assert_eq!(
477            response.headers.get(http::header::LOCATION).unwrap(),
478            "/console"
479        );
480        assert_eq!(
481            diagnosed.diagnosis().operation,
482            AuthFlowOperation::BASIC_AUTH_LOGIN
483        );
484        assert_eq!(
485            diagnosed.diagnosis().outcome,
486            AuthFlowDiagnosisOutcome::Succeeded
487        );
488        assert_eq!(diagnosed.diagnosis().fields["authenticated"], true);
489    }
490
491    #[test]
492    fn authorize_request_diagnosed_reports_verified_credentials() {
493        let context = test_context();
494        let service = BasicAuthContextService::new(&context).expect("service should build");
495        let diagnosed = service.authorize_request_diagnosed(Some("Basic YWRtaW46c2VjcmV0"), None);
496
497        assert!(
498            diagnosed
499                .result()
500                .as_ref()
501                .is_ok_and(|authorized| *authorized)
502        );
503        assert_eq!(
504            diagnosed.diagnosis().operation,
505            AuthFlowOperation::BASIC_AUTH_AUTHORIZE
506        );
507        assert_eq!(
508            diagnosed.diagnosis().outcome,
509            AuthFlowDiagnosisOutcome::Succeeded
510        );
511        assert_eq!(diagnosed.diagnosis().fields["authorized"], true);
512    }
513
514    #[test]
515    fn logout_diagnosed_reports_logout_poison_protocol_response() {
516        let context = test_context();
517        let service = BasicAuthContextService::new(&context).expect("service should build");
518        let diagnosed = service.logout_diagnosed("/basic/logout");
519        let response = diagnosed
520            .result()
521            .as_ref()
522            .expect("logout should produce response");
523
524        assert_eq!(response.status, StatusCode::UNAUTHORIZED);
525        assert_eq!(
526            diagnosed.diagnosis().operation,
527            AuthFlowOperation::BASIC_AUTH_LOGOUT
528        );
529        assert_eq!(
530            diagnosed.diagnosis().outcome,
531            AuthFlowDiagnosisOutcome::Succeeded
532        );
533        assert_eq!(
534            diagnosed.diagnosis().fields["response_kind"],
535            "logout_poison"
536        );
537    }
538}