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::{BasicAuthContext, BasicAuthContextConfig, BasicAuthZoneConfig};
374
375    #[derive(Debug, Clone, Serialize, Deserialize, Default)]
376    struct TestCred {
377        username: String,
378        password: String,
379    }
380
381    impl BasicAuthCred for TestCred {
382        fn username(&self) -> &str {
383            &self.username
384        }
385
386        fn verify_password(&self, password: &str) -> securitydept_creds::CredsResult<bool> {
387            Ok(password == self.password)
388        }
389    }
390
391    fn test_context() -> BasicAuthContext<TestCred> {
392        BasicAuthContext::from_config(
393            BasicAuthContextConfig::builder()
394                .creds(securitydept_creds::BasicAuthCredsConfig {
395                    users: vec![TestCred {
396                        username: "admin".to_string(),
397                        password: "secret".to_string(),
398                    }],
399                })
400                .zones(vec![BasicAuthZoneConfig::default()])
401                .build(),
402        )
403        .expect("context should build")
404    }
405
406    fn test_context_with_dynamic_redirect() -> BasicAuthContext<TestCred> {
407        BasicAuthContext::from_config(
408            BasicAuthContextConfig::builder()
409                .creds(securitydept_creds::BasicAuthCredsConfig {
410                    users: vec![TestCred {
411                        username: "admin".to_string(),
412                        password: "secret".to_string(),
413                    }],
414                })
415                .zones(vec![BasicAuthZoneConfig::builder()
416                    .post_auth_redirect(
417                        securitydept_utils::redirect::RedirectTargetConfig::dynamic_default_and_dynamic_targets(
418                            "/",
419                            [securitydept_utils::redirect::RedirectTargetRule::Strict {
420                                value: "/console".to_string(),
421                            }],
422                        ),
423                    )
424                    .build()])
425                .build(),
426        )
427        .expect("context should build")
428    }
429
430    #[test]
431    fn login_without_credentials_returns_challenge() {
432        let context = test_context();
433        let service = BasicAuthContextService::new(&context).expect("service should build");
434        let diagnosed = service.login_diagnosed("/basic/login", None, None, None);
435        let response = diagnosed
436            .result()
437            .as_ref()
438            .expect("login should return response");
439
440        assert_eq!(response.status, StatusCode::UNAUTHORIZED);
441        assert_eq!(
442            diagnosed.diagnosis().operation,
443            AuthFlowOperation::BASIC_AUTH_LOGIN
444        );
445        assert_eq!(
446            diagnosed.diagnosis().outcome,
447            AuthFlowDiagnosisOutcome::Rejected
448        );
449        assert_eq!(diagnosed.diagnosis().fields["reason"], "challenge_required");
450    }
451
452    #[test]
453    fn login_with_valid_credentials_redirects() {
454        let context = test_context_with_dynamic_redirect();
455        let service = BasicAuthContextService::new(&context).expect("service should build");
456        let diagnosed = service.login_diagnosed(
457            "/basic/login",
458            Some("Basic YWRtaW46c2VjcmV0"),
459            Some("/console"),
460            None,
461        );
462        let response = diagnosed
463            .result()
464            .as_ref()
465            .expect("login should return response");
466
467        assert_eq!(response.status, StatusCode::FOUND);
468        assert_eq!(
469            response.headers.get(http::header::LOCATION).unwrap(),
470            "/console"
471        );
472        assert_eq!(
473            diagnosed.diagnosis().operation,
474            AuthFlowOperation::BASIC_AUTH_LOGIN
475        );
476        assert_eq!(
477            diagnosed.diagnosis().outcome,
478            AuthFlowDiagnosisOutcome::Succeeded
479        );
480        assert_eq!(diagnosed.diagnosis().fields["authenticated"], true);
481    }
482
483    #[test]
484    fn authorize_request_diagnosed_reports_verified_credentials() {
485        let context = test_context();
486        let service = BasicAuthContextService::new(&context).expect("service should build");
487        let diagnosed = service.authorize_request_diagnosed(Some("Basic YWRtaW46c2VjcmV0"), None);
488
489        assert!(
490            diagnosed
491                .result()
492                .as_ref()
493                .is_ok_and(|authorized| *authorized)
494        );
495        assert_eq!(
496            diagnosed.diagnosis().operation,
497            AuthFlowOperation::BASIC_AUTH_AUTHORIZE
498        );
499        assert_eq!(
500            diagnosed.diagnosis().outcome,
501            AuthFlowDiagnosisOutcome::Succeeded
502        );
503        assert_eq!(diagnosed.diagnosis().fields["authorized"], true);
504    }
505
506    #[test]
507    fn logout_diagnosed_reports_logout_poison_protocol_response() {
508        let context = test_context();
509        let service = BasicAuthContextService::new(&context).expect("service should build");
510        let diagnosed = service.logout_diagnosed("/basic/logout");
511        let response = diagnosed
512            .result()
513            .as_ref()
514            .expect("logout should produce response");
515
516        assert_eq!(response.status, StatusCode::UNAUTHORIZED);
517        assert_eq!(
518            diagnosed.diagnosis().operation,
519            AuthFlowOperation::BASIC_AUTH_LOGOUT
520        );
521        assert_eq!(
522            diagnosed.diagnosis().outcome,
523            AuthFlowDiagnosisOutcome::Succeeded
524        );
525        assert_eq!(
526            diagnosed.diagnosis().fields["response_kind"],
527            "logout_poison"
528        );
529    }
530}