Skip to main content

openstack_sdk_auth_core/
authtoken.rs

1// Licensed under the Apache License, Version 2.0 (the "License");
2// you may not use this file except in compliance with the License.
3// You may obtain a copy of the License at
4//
5//     http://www.apache.org/licenses/LICENSE-2.0
6//
7// Unless required by applicable law or agreed to in writing, software
8// distributed under the License is distributed on an "AS IS" BASIS,
9// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10// See the License for the specific language governing permissions and
11// limitations under the License.
12//
13// SPDX-License-Identifier: Apache-2.0
14
15//! OpenStack AuthToken based authorization (X-Auth-Token)
16
17use 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/// AuthToken (X-Auth-Token) based auth errors
31#[derive(Debug, Error)]
32#[non_exhaustive]
33pub enum AuthTokenError {
34    /// Auth request preparation error.
35    #[error("error preparing authentication request: {}", source)]
36    AuthRequest {
37        /// The source of the error.
38        #[source]
39        source: Box<dyn std::error::Error + Send + Sync + 'static>,
40    },
41
42    /// Token is missing in the authentication response.
43    #[error("token missing in the response")]
44    AuthTokenNotInResponse,
45
46    /// X-Subject-Token cannot be converted to string.
47    #[error("token missing cannot be converted to string")]
48    AuthTokenNotString,
49
50    /// Header error
51    #[error("header value error: {}", source)]
52    HeaderValue {
53        #[from]
54        source: http::header::InvalidHeaderValue,
55    },
56
57    /// Unsupported identity method
58    #[error(
59        "AuthType `{}` is not a supported type for authenticating towards the cloud",
60        auth_type
61    )]
62    IdentityMethod { auth_type: String },
63
64    /// Unsupported identity method in sync mode
65    #[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    /// Auth data is missing
72    #[error("Auth data is missing")]
73    MissingAuthData,
74
75    /// auth_url is missing
76    #[error("Auth URL is missing")]
77    MissingAuthUrl,
78
79    /// Multifactor `auth_type` requires `auth_methods` to be an array of strings.
80    #[error("`auth_methods` must be an array of string when `auth_type=multifactor`")]
81    MultifactorAuthMethodsList,
82
83    /// Token Scope error
84    #[error("Scope error: {}", source)]
85    Scope {
86        /// The error source
87        #[from]
88        source: AuthTokenScopeError,
89    },
90
91    /// (De)Serialization error.
92    #[error("failed to deserialize response body: {}", source)]
93    Serde {
94        /// The source of the error.
95        #[from]
96        source: serde_json::Error,
97    },
98
99    /// AuthPlugin error.
100    #[error("plugin error: {}", source)]
101    Plugin {
102        /// The source of the error.
103        #[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
127/// Explicitly implement From to easier propagate nested errors
128impl 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/// OpenStack AuthToken authorization structure
139#[derive(Clone, Default, Deserialize, Serialize)]
140pub struct AuthToken {
141    /// Token itself
142    #[serde(serialize_with = "serialize_secret_string")]
143    pub token: SecretString,
144    /// Auth info reported by the server
145    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    /// Construct new AuthToken instance.
174    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    /// Adds X-Auth-Token header to a request headers.
184    ///
185    /// Returns an error if the token string cannot be parsed as a header value.
186    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    /// Detect authentication validity (valid/expired/unset)
195    ///
196    /// Offset can be used to calculate imminent expiration.
197    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    /// Get Token scope information
218    pub fn get_scope(&self) -> AuthTokenScope {
219        match &self.auth_info {
220            Some(data) => AuthTokenScope::from(data),
221            _ => AuthTokenScope::Unscoped,
222        }
223    }
224
225    /// Parse [`Response`] into the AuthToken.
226    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
255/// Build the [`AuthError`] for a non-success Keystone auth response, given its status,
256/// headers, and already-read body text.
257///
258/// Handles Keystone's several error-response shapes: a multi-factor auth receipt (401 with
259/// an `openstack-auth-receipt` header), a structured identity error body, or an opaque
260/// fallback carrying the raw body text.
261fn 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/// Fuzz target entry point for the otherwise private [`parse_error_response`].
296///
297/// Only compiled with the `fuzzing` feature; not part of the stable public API.
298#[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        // reqwest wraps serde errors in reqwest::Error, so it becomes AuthError::Serde or AuthError::Reqwest
463        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        // With zero offset, soon_expiration == expiration, so AboutToExpire is unreachable
631        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}