1use std::fmt;
18use std::fmt::Debug;
19
20use http::{HeaderMap, HeaderValue};
21use reqwest::{Response, StatusCode};
22use secrecy::{ExposeSecret, SecretString};
23use serde::{Deserialize, Serialize, Serializer};
24use thiserror::Error;
25
26use crate::authtoken_scope::*;
27use crate::types::*;
28use crate::{AuthError, AuthState};
29
30#[derive(Debug, Error)]
32#[non_exhaustive]
33pub enum AuthTokenError {
34 #[error("error preparing authentication request: {}", source)]
36 AuthRequest {
37 #[source]
39 source: Box<dyn std::error::Error + Send + Sync + 'static>,
40 },
41
42 #[error("token missing in the response")]
44 AuthTokenNotInResponse,
45
46 #[error("token missing cannot be converted to string")]
48 AuthTokenNotString,
49
50 #[error("header value error: {}", source)]
52 HeaderValue {
53 #[from]
54 source: http::header::InvalidHeaderValue,
55 },
56
57 #[error(
59 "AuthType `{}` is not a supported type for authenticating towards the cloud",
60 auth_type
61 )]
62 IdentityMethod { auth_type: String },
63
64 #[error(
66 "AuthType `{}` is not a supported type for authenticating towards the cloud with sync interface",
67 auth_type
68 )]
69 IdentityMethodSync { auth_type: String },
70
71 #[error("Auth data is missing")]
73 MissingAuthData,
74
75 #[error("Auth URL is missing")]
77 MissingAuthUrl,
78
79 #[error("`auth_methods` must be an array of string when `auth_type=multifactor`")]
81 MultifactorAuthMethodsList,
82
83 #[error("Scope error: {}", source)]
85 Scope {
86 #[from]
88 source: AuthTokenScopeError,
89 },
90
91 #[error("failed to deserialize response body: {}", source)]
93 Serde {
94 #[from]
96 source: serde_json::Error,
97 },
98
99 #[error("plugin error: {}", source)]
101 Plugin {
102 #[source]
104 source: Box<dyn std::error::Error + Send + Sync + 'static>,
105 },
106}
107
108impl AuthTokenError {
109 pub fn auth_request<E>(error: E) -> Self
110 where
111 E: std::error::Error + Send + Sync + 'static,
112 {
113 Self::AuthRequest {
114 source: Box::new(error),
115 }
116 }
117 pub fn plugin<E>(error: E) -> Self
118 where
119 E: std::error::Error + Send + Sync + 'static,
120 {
121 Self::Plugin {
122 source: Box::new(error),
123 }
124 }
125}
126
127impl From<AuthTokenScopeError> for AuthError {
129 fn from(source: AuthTokenScopeError) -> Self {
130 Self::AuthToken {
131 source: AuthTokenError::Scope { source },
132 }
133 }
134}
135
136type AuthResult<T> = Result<T, AuthTokenError>;
137
138#[derive(Clone, Default, Deserialize, Serialize)]
140pub struct AuthToken {
141 #[serde(serialize_with = "serialize_secret_string")]
143 pub token: SecretString,
144 pub auth_info: Option<AuthResponse>,
146}
147
148fn serialize_secret_string<S>(secret: &SecretString, serializer: S) -> Result<S::Ok, S::Error>
149where
150 S: Serializer,
151{
152 serializer.serialize_str(secret.expose_secret())
153}
154
155impl From<&str> for AuthToken {
156 fn from(value: &str) -> Self {
157 Self {
158 token: value.into(),
159 ..Default::default()
160 }
161 }
162}
163
164impl Debug for AuthToken {
165 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
166 f.debug_struct("AuthToken")
167 .field("data", &self.auth_info)
168 .finish()
169 }
170}
171
172impl AuthToken {
173 pub fn new<T>(token: T, auth_info: Option<AuthResponse>) -> Self
175 where
176 T: Into<SecretString>,
177 {
178 Self {
179 token: token.into(),
180 auth_info,
181 }
182 }
183 pub fn set_header(&self, headers: &mut HeaderMap<HeaderValue>) -> AuthResult<()> {
187 let mut token_header_value = HeaderValue::from_str(self.token.expose_secret())?;
188 token_header_value.set_sensitive(true);
189 headers.insert("X-Auth-Token", token_header_value);
190
191 Ok(())
192 }
193
194 pub fn get_state(&self, expiration_offset: Option<chrono::TimeDelta>) -> AuthState {
198 let expiration = chrono::Utc::now();
199 let soon_expiration = match expiration_offset {
200 Some(offset) => expiration + offset,
201 None => expiration,
202 };
203 match &self.auth_info {
204 Some(data) => {
205 if data.token.expires_at <= expiration {
206 AuthState::Expired
207 } else if data.token.expires_at <= soon_expiration {
208 AuthState::AboutToExpire
209 } else {
210 AuthState::Valid
211 }
212 }
213 None => AuthState::Unset,
214 }
215 }
216
217 pub fn get_scope(&self) -> AuthTokenScope {
219 match &self.auth_info {
220 Some(data) => AuthTokenScope::from(data),
221 _ => AuthTokenScope::Unscoped,
222 }
223 }
224
225 pub async fn from_reqwest_response(response: Response) -> Result<Self, AuthError> {
227 if !response.status().is_success() {
228 let status = response.status();
229 let headers = response.headers().clone();
230 let body_text = response.text().await?;
231 return Err(parse_error_response(status, &headers, &body_text));
232 }
233
234 let token = response
235 .headers()
236 .get("x-subject-token")
237 .ok_or(AuthError::AuthToken {
238 source: AuthTokenError::AuthTokenNotInResponse,
239 })?
240 .to_str()
241 .map_err(|_| AuthError::AuthToken {
242 source: AuthTokenError::AuthTokenNotString,
243 })?
244 .to_string();
245
246 let token_info: AuthResponse = response.json::<AuthResponse>().await?;
247
248 Ok(Self {
249 token: SecretString::from(token),
250 auth_info: Some(token_info),
251 })
252 }
253}
254
255fn parse_error_response(status: StatusCode, headers: &HeaderMap, body_text: &str) -> AuthError {
262 if let StatusCode::UNAUTHORIZED = status
263 && let Some(receipt_header) = headers.get("openstack-auth-receipt")
264 {
265 let receipt_token = match receipt_header.to_str() {
266 Ok(v) => v.into(),
267 Err(_) => return AuthError::AuthReceiptNotString,
268 };
269
270 if let Ok(mut receipt) = serde_json::from_str::<AuthReceiptResponse>(body_text) {
271 receipt.token = Some(receipt_token);
272 return AuthError::AuthReceipt(Box::new(receipt));
273 }
274
275 return if let Ok(data) = serde_json::from_str::<AuthErrorResponse>(body_text) {
276 AuthError::Identity(data.error)
277 } else {
278 AuthError::UnknownAuth {
279 code: status.into(),
280 message: Some(body_text.to_string()),
281 }
282 };
283 }
284
285 if let Ok(data) = serde_json::from_str::<AuthErrorResponse>(body_text) {
286 AuthError::Identity(data.error)
287 } else {
288 AuthError::UnknownAuth {
289 code: status.into(),
290 message: Some(body_text.to_string()),
291 }
292 }
293}
294
295#[cfg(feature = "fuzzing")]
299pub fn fuzz_parse_error_response(
300 status: StatusCode,
301 headers: &HeaderMap,
302 body_text: &str,
303) -> AuthError {
304 parse_error_response(status, headers, body_text)
305}
306
307impl TryFrom<http::Response<bytes::Bytes>> for AuthToken {
308 type Error = AuthTokenError;
309 fn try_from(value: http::Response<bytes::Bytes>) -> Result<Self, Self::Error> {
310 let token = value
311 .headers()
312 .get("x-subject-token")
313 .ok_or(AuthTokenError::AuthTokenNotInResponse)?
314 .to_str()
315 .map_err(|_| AuthTokenError::AuthTokenNotString)?;
316
317 let token_info: AuthResponse = serde_json::from_slice(value.body())?;
318 Ok(Self::new(token, Some(token_info)))
319 }
320}
321
322#[cfg(test)]
323mod tests {
324 use chrono::Local;
325 use http::response::Builder;
326 use reqwest::Response;
327 use secrecy::ExposeSecret;
328 use serde_json::to_string;
329
330 use super::AuthError;
331 use super::AuthToken;
332 use super::AuthTokenError;
333 use crate::AuthState;
334 use crate::authtoken_scope::AuthTokenScope;
335 use crate::authtoken_scope::AuthTokenScopeError;
336 use crate::types::*;
337 use http::HeaderMap;
338
339 #[tokio::test]
340 async fn test_from_reqwest_response_receipt() {
341 let auth_receipt = AuthReceiptResponse {
342 receipt: AuthReceipt {
343 methods: vec!["password".into()],
344 user: User {
345 id: "uid".into(),
346 name: "uname".into(),
347 ..Default::default()
348 },
349 expires_at: Local::now(),
350 ..Default::default()
351 },
352 required_auth_methods: vec![vec!["totp".into(), "password".into()]],
353 token: None,
354 };
355 let http_response = Builder::new()
356 .status(401)
357 .header("openstack-auth-receipt", "foobar")
358 .header("content-type", "application/json")
359 .body(to_string(&auth_receipt).unwrap())
360 .unwrap();
361
362 let response: Response = Response::from(http_response);
363
364 let rsp = AuthToken::from_reqwest_response(response).await;
365 match rsp {
366 Err(AuthError::AuthReceipt(receipt)) => {
367 let mut expected = auth_receipt.clone();
368 expected.token = Some("foobar".into());
369 assert_eq!(expected, *receipt);
370 }
371 other => {
372 panic!("wrong response for the expected receipt error: {:?}", other);
373 }
374 }
375 }
376
377 #[tokio::test]
378 async fn test_from_reqwest_response_error() {
379 let err = AuthErrorResponse {
380 error: IdentityError {
381 code: 401,
382 message: "internal error".into(),
383 },
384 };
385 let http_response = Builder::new()
386 .status(401)
387 .header("content-type", "application/json")
388 .body(to_string(&err).unwrap())
389 .unwrap();
390
391 let response: Response = Response::from(http_response);
392
393 let rsp = AuthToken::from_reqwest_response(response).await;
394 match rsp {
395 Err(AuthError::Identity(error)) => {
396 assert_eq!(error, err.error);
397 }
398 other => {
399 panic!("wrong response: {:?}", other);
400 }
401 }
402 }
403
404 #[tokio::test]
405 async fn test_from_reqwest_response_success() {
406 let auth = AuthResponse::default();
407 let http_response = Builder::new()
408 .status(201)
409 .header("content-type", "application/json")
410 .header("x-subject-token", "foobar")
411 .body(to_string(&auth).unwrap())
412 .unwrap();
413
414 let response: Response = Response::from(http_response);
415
416 let rsp = AuthToken::from_reqwest_response(response).await;
417 match rsp {
418 Ok(rsp) => {
419 assert_eq!(auth, rsp.auth_info.unwrap());
420 assert_eq!("foobar", rsp.token.expose_secret());
421 }
422 other => {
423 panic!("wrong response: {:?}", other);
424 }
425 }
426 }
427
428 #[tokio::test]
429 async fn test_from_reqwest_response_success_no_token() {
430 let auth = AuthResponse::default();
431 let http_response = Builder::new()
432 .status(201)
433 .header("content-type", "application/json")
434 .body(to_string(&auth).unwrap())
435 .unwrap();
436
437 let response: Response = Response::from(http_response);
438
439 let rsp = AuthToken::from_reqwest_response(response).await;
440 match rsp {
441 Err(AuthError::AuthToken {
442 source: AuthTokenError::AuthTokenNotInResponse,
443 }) => {}
444 other => {
445 panic!("wrong response: {:?}", other);
446 }
447 }
448 }
449
450 #[tokio::test]
451 async fn test_from_reqwest_response_malformed_json_success() {
452 let http_response = Builder::new()
453 .status(201)
454 .header("content-type", "application/json")
455 .header("x-subject-token", "foobar")
456 .body(String::from("{invalid"))
457 .unwrap();
458
459 let response: Response = Response::from(http_response);
460
461 let rsp = AuthToken::from_reqwest_response(response).await;
462 assert!(matches!(
464 rsp,
465 Err(AuthError::Serde { .. }) | Err(AuthError::Reqwest { .. })
466 ));
467 }
468
469 #[tokio::test]
470 async fn test_from_reqwest_response_500_json_error() {
471 let err = AuthErrorResponse {
472 error: IdentityError {
473 code: 500,
474 message: "internal server error".into(),
475 },
476 };
477 let http_response = Builder::new()
478 .status(500)
479 .header("content-type", "application/json")
480 .body(to_string(&err).unwrap())
481 .unwrap();
482
483 let response: Response = Response::from(http_response);
484
485 let rsp = AuthToken::from_reqwest_response(response).await;
486 match rsp {
487 Err(AuthError::Identity(e)) => assert_eq!(e.code, 500),
488 other => panic!("wrong response: {:?}", other),
489 }
490 }
491
492 #[tokio::test]
493 async fn test_from_reqwest_response_502_plain_text() {
494 let http_response = Builder::new()
495 .status(502)
496 .body(String::from("upstream timeout"))
497 .unwrap();
498
499 let response: Response = Response::from(http_response);
500
501 let rsp = AuthToken::from_reqwest_response(response).await;
502 match rsp {
503 Err(AuthError::UnknownAuth { code, message }) => {
504 assert_eq!(code, 502);
505 assert_eq!(message, Some("upstream timeout".into()));
506 }
507 other => panic!("wrong response: {:?}", other),
508 }
509 }
510
511 #[tokio::test]
512 async fn test_from_reqwest_response_403_identity_error() {
513 let err = AuthErrorResponse {
514 error: IdentityError {
515 code: 403,
516 message: "forbidden".into(),
517 },
518 };
519 let http_response = Builder::new()
520 .status(403)
521 .header("content-type", "application/json")
522 .body(to_string(&err).unwrap())
523 .unwrap();
524
525 let response: Response = Response::from(http_response);
526
527 let rsp = AuthToken::from_reqwest_response(response).await;
528 match rsp {
529 Err(AuthError::Identity(e)) => assert_eq!(e.code, 403),
530 other => panic!("wrong response: {:?}", other),
531 }
532 }
533
534 #[tokio::test]
535 async fn test_from_reqwest_response_401_receipt_malformed_json() {
536 let http_response = Builder::new()
537 .status(401)
538 .header("openstack-auth-receipt", "foobar")
539 .body(String::from("{malformed"))
540 .unwrap();
541
542 let response: Response = Response::from(http_response);
543
544 let rsp = AuthToken::from_reqwest_response(response).await;
545 match rsp {
546 Err(AuthError::UnknownAuth { code, message }) => {
547 assert_eq!(code, 401);
548 assert_eq!(message, Some("{malformed".into()));
549 }
550 other => panic!("wrong response: {:?}", other),
551 }
552 }
553
554 #[tokio::test]
555 async fn test_from_reqwest_response_status_200() {
556 let auth = AuthResponse::default();
557 let http_response = Builder::new()
558 .status(200)
559 .header("content-type", "application/json")
560 .header("x-subject-token", "tok")
561 .body(to_string(&auth).unwrap())
562 .unwrap();
563
564 let response: Response = Response::from(http_response);
565
566 let rsp = AuthToken::from_reqwest_response(response).await;
567 assert!(rsp.is_ok());
568 assert_eq!(rsp.unwrap().token.expose_secret(), "tok");
569 }
570
571 #[test]
572 fn test_auth_token_from_str() {
573 let auth: AuthToken = "my-secret-token".into();
574 assert_eq!("my-secret-token", auth.token.expose_secret());
575 assert!(auth.auth_info.is_none());
576 }
577
578 #[test]
579 fn test_auth_token_set_header_success() {
580 let auth = AuthToken::new("valid-token", None);
581 let mut headers = HeaderMap::new();
582 auth.set_header(&mut headers).unwrap();
583 let val = headers.get("X-Auth-Token").expect("header missing");
584 assert_eq!(val.to_str().unwrap(), "valid-token");
585 }
586
587 #[test]
588 fn test_auth_token_debug_no_secret_leak() {
589 let auth = AuthToken::new("super-secret", None);
590 let debug = format!("{:?}", auth);
591 assert!(debug.contains("AuthToken"));
592 assert!(!debug.contains("super-secret"));
593 }
594
595 #[test]
596 fn test_auth_token_get_scope_unscoped() {
597 let auth = AuthToken::new("tok", None);
598 assert!(matches!(auth.get_scope(), AuthTokenScope::Unscoped));
599 }
600
601 #[test]
602 fn test_auth_token_get_scope_project() {
603 let auth = AuthToken::new(
604 "tok",
605 Some(AuthResponse {
606 token: TokenInfo {
607 project: Some(Project {
608 id: Some("1".into()),
609 name: Some("p".into()),
610 domain: None,
611 }),
612 ..Default::default()
613 },
614 }),
615 );
616 assert!(matches!(auth.get_scope(), AuthTokenScope::Project(_)));
617 }
618
619 #[test]
620 fn test_auth_token_get_state_zero_offset() {
621 let auth = AuthToken::new(
622 String::new(),
623 Some(AuthResponse {
624 token: TokenInfo {
625 expires_at: chrono::Utc::now() + chrono::TimeDelta::milliseconds(500),
626 ..Default::default()
627 },
628 }),
629 );
630 assert!(matches!(
632 auth.get_state(Some(chrono::TimeDelta::zero())),
633 AuthState::Valid
634 ));
635 }
636
637 #[test]
638 fn test_auth_token_get_state_expires_exactly_at_offset() {
639 let expires = chrono::Utc::now() + chrono::TimeDelta::minutes(10);
640 let auth = AuthToken::new(
641 String::new(),
642 Some(AuthResponse {
643 token: TokenInfo {
644 expires_at: expires,
645 ..Default::default()
646 },
647 }),
648 );
649 assert!(matches!(
650 auth.get_state(Some(chrono::TimeDelta::minutes(10))),
651 AuthState::AboutToExpire
652 ));
653 }
654
655 #[test]
656 fn test_auth_token_get_state_no_offset_valid() {
657 let auth = AuthToken::new(
658 String::new(),
659 Some(AuthResponse {
660 token: TokenInfo {
661 expires_at: chrono::Utc::now() + chrono::TimeDelta::seconds(1),
662 ..Default::default()
663 },
664 }),
665 );
666 assert!(matches!(auth.get_state(None), AuthState::Valid));
667 }
668
669 #[test]
670 fn test_auth_token_get_state_expires_exactly_now() {
671 let now = chrono::Utc::now();
672 let auth = AuthToken::new(
673 String::new(),
674 Some(AuthResponse {
675 token: TokenInfo {
676 expires_at: now,
677 ..Default::default()
678 },
679 }),
680 );
681 assert!(matches!(
682 auth.get_state(Some(chrono::TimeDelta::minutes(1))),
683 AuthState::Expired
684 ));
685 }
686
687 #[test]
688 fn test_try_from_http_response_success() {
689 let auth = AuthResponse::default();
690 let body_bytes: bytes::Bytes = to_string(&auth).unwrap().into_bytes().into();
691
692 let http_response = http::Response::builder()
693 .status(201)
694 .header("content-type", "application/json")
695 .header("x-subject-token", "tok")
696 .body(body_bytes)
697 .unwrap();
698
699 let result = AuthToken::try_from(http_response);
700 assert!(result.is_ok());
701 let token = result.unwrap();
702 assert_eq!("tok", token.token.expose_secret());
703 }
704
705 #[test]
706 fn test_try_from_http_response_no_token() {
707 let auth = AuthResponse::default();
708 let body_bytes: bytes::Bytes = to_string(&auth).unwrap().into_bytes().into();
709
710 let http_response = http::Response::builder()
711 .status(201)
712 .header("content-type", "application/json")
713 .body(body_bytes)
714 .unwrap();
715
716 let result = AuthToken::try_from(http_response);
717 assert!(matches!(
718 result,
719 Err(AuthTokenError::AuthTokenNotInResponse)
720 ));
721 }
722
723 #[test]
724 fn test_try_from_http_response_malformed_json() {
725 let http_response = http::Response::builder()
726 .status(201)
727 .header("x-subject-token", "tok")
728 .body(bytes::Bytes::from_static(b"{bad"))
729 .unwrap();
730
731 let result = AuthToken::try_from(http_response);
732 assert!(matches!(result, Err(AuthTokenError::Serde { .. })));
733 }
734
735 #[test]
736 fn test_auth_token_error_auth_request() {
737 #[derive(Debug)]
738 struct MyErr(&'static str);
739 impl std::fmt::Display for MyErr {
740 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
741 write!(f, "{}", self.0)
742 }
743 }
744 impl std::error::Error for MyErr {}
745
746 let err = AuthTokenError::auth_request(MyErr("req"));
747 assert!(matches!(err, AuthTokenError::AuthRequest { .. }));
748 assert!(format!("{}", err).contains("req"));
749 }
750
751 #[test]
752 fn test_auth_token_error_plugin() {
753 #[derive(Debug)]
754 struct MyErr(&'static str);
755 impl std::fmt::Display for MyErr {
756 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
757 write!(f, "{}", self.0)
758 }
759 }
760 impl std::error::Error for MyErr {}
761
762 let err = AuthTokenError::plugin(MyErr("plug"));
763 assert!(matches!(err, AuthTokenError::Plugin { .. }));
764 assert!(format!("{}", err).contains("plug"));
765 }
766
767 #[test]
768 fn test_from_auth_token_scope_error_to_auth_error() {
769 let scope_err = AuthTokenScopeError::MissingScope;
770 let auth_err: AuthError = scope_err.into();
771 assert!(matches!(
772 auth_err,
773 AuthError::AuthToken {
774 source: AuthTokenError::Scope { .. }
775 }
776 ));
777 }
778
779 #[test]
780 fn test_auth_token_error_identity_method_display() {
781 let err = AuthTokenError::IdentityMethod {
782 auth_type: "jwt".into(),
783 };
784 assert!(format!("{}", err).contains("jwt"));
785 }
786
787 #[test]
788 fn test_auth_token_error_missing_data_display() {
789 let err = AuthTokenError::MissingAuthData;
790 assert_eq!(format!("{}", err), "Auth data is missing");
791 }
792
793 #[test]
794 fn test_auth_token_error_missing_url_display() {
795 let err = AuthTokenError::MissingAuthUrl;
796 assert_eq!(format!("{}", err), "Auth URL is missing");
797 }
798}