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("Auth")
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<'a>(
187        &self,
188        headers: &'a mut HeaderMap<HeaderValue>,
189    ) -> AuthResult<&'a mut HeaderMap<HeaderValue>> {
190        let mut token_header_value = HeaderValue::from_str(self.token.expose_secret())?;
191        token_header_value.set_sensitive(true);
192        headers.insert("X-Auth-Token", token_header_value);
193
194        Ok(headers)
195    }
196
197    /// Detect authentication validity (valid/expired/unset)
198    ///
199    /// Offset can be used to calculate imminent expiration.
200    pub fn get_state(&self, expiration_offset: Option<chrono::TimeDelta>) -> AuthState {
201        let expiration = chrono::Utc::now();
202        let soon_expiration = match expiration_offset {
203            Some(offset) => expiration + offset,
204            None => expiration,
205        };
206        match &self.auth_info {
207            Some(data) => {
208                if data.token.expires_at <= expiration {
209                    AuthState::Expired
210                } else if data.token.expires_at <= soon_expiration {
211                    AuthState::AboutToExpire
212                } else {
213                    AuthState::Valid
214                }
215            }
216            None => AuthState::Unset,
217        }
218    }
219
220    /// Get Token scope information
221    pub fn get_scope(&self) -> AuthTokenScope {
222        match &self.auth_info {
223            Some(data) => AuthTokenScope::from(data),
224            _ => AuthTokenScope::Unscoped,
225        }
226    }
227
228    /// Parse [`Response`] into the AuthToken.
229    pub async fn from_reqwest_response(response: Response) -> Result<Self, AuthError> {
230        if !response.status().is_success() {
231            // Handle the MFA
232            let status = response.status();
233            if let StatusCode::UNAUTHORIZED = status
234                && let Some(receipt) = response.headers().get("openstack-auth-receipt")
235            {
236                let receipt_token = receipt
237                    .to_str()
238                    .map_err(|_| AuthError::AuthReceiptNotString)?
239                    .into();
240                let mut receipt: AuthReceiptResponse = response.json().await?;
241                receipt.token = Some(receipt_token);
242                return Err(AuthError::AuthReceipt(receipt));
243            }
244
245            let body = response.text().await?;
246
247            if let Ok(data) = serde_json::from_str::<AuthErrorResponse>(&body) {
248                return Err(AuthError::Identity(data.error));
249            } else {
250                return Err(AuthError::UnknownAuth {
251                    code: status.into(),
252                    message: Some(body),
253                });
254            }
255        }
256
257        let token = response
258            .headers()
259            .get("x-subject-token")
260            .ok_or(AuthError::AuthTokenNotInResponse)?
261            .to_str()
262            .map_err(|_| AuthError::AuthTokenNotString)?
263            .to_string();
264
265        let token_info: AuthResponse = response.json::<AuthResponse>().await?;
266
267        Ok(Self {
268            token: SecretString::from(token),
269            auth_info: Some(token_info),
270        })
271    }
272}
273
274impl TryFrom<http::Response<bytes::Bytes>> for AuthToken {
275    type Error = AuthTokenError;
276    fn try_from(value: http::Response<bytes::Bytes>) -> Result<Self, Self::Error> {
277        let token = value
278            .headers()
279            .get("x-subject-token")
280            .ok_or(AuthTokenError::AuthTokenNotInResponse)?
281            .to_str()
282            .map_err(|_| AuthTokenError::AuthTokenNotString)?;
283
284        let token_info: AuthResponse = serde_json::from_slice(value.body())?;
285        Ok(Self::new(token, Some(token_info)))
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use chrono::Local;
292    use http::response::Builder;
293    use reqwest::Response;
294    use secrecy::ExposeSecret;
295    use serde_json::to_string;
296
297    use super::AuthError;
298    use super::AuthToken;
299    use crate::types::*;
300
301    #[tokio::test]
302    async fn test_from_reqwest_response_receipt() {
303        let auth_receipt = AuthReceiptResponse {
304            receipt: AuthReceipt {
305                methods: vec!["password".into()],
306                user: User {
307                    id: "uid".into(),
308                    name: "uname".into(),
309                    ..Default::default()
310                },
311                expires_at: Local::now(),
312                ..Default::default()
313            },
314            required_auth_methods: vec![vec!["totp".into(), "password".into()]],
315            token: None,
316        };
317        let http_response = Builder::new()
318            .status(401)
319            .header("openstack-auth-receipt", "foobar")
320            .header("content-type", "application/json")
321            .body(to_string(&auth_receipt).unwrap())
322            .unwrap();
323
324        let response: Response = Response::from(http_response);
325
326        let rsp = AuthToken::from_reqwest_response(response).await;
327        match rsp {
328            Err(AuthError::AuthReceipt(receipt)) => {
329                let mut expected = auth_receipt.clone();
330                expected.token = Some("foobar".into());
331                assert_eq!(expected, receipt);
332            }
333            other => {
334                panic!("wrong response for the expected receipt error: {:?}", other);
335            }
336        }
337    }
338
339    #[tokio::test]
340    async fn test_from_reqwest_response_error() {
341        let err = AuthErrorResponse {
342            error: IdentityError {
343                code: 401,
344                message: "internal error".into(),
345            },
346        };
347        let http_response = Builder::new()
348            .status(401)
349            .header("content-type", "application/json")
350            .body(to_string(&err).unwrap())
351            .unwrap();
352
353        let response: Response = Response::from(http_response);
354
355        let rsp = AuthToken::from_reqwest_response(response).await;
356        match rsp {
357            Err(AuthError::Identity(error)) => {
358                assert_eq!(error, err.error);
359            }
360            other => {
361                panic!("wrong response: {:?}", other);
362            }
363        }
364    }
365
366    #[tokio::test]
367    async fn test_from_reqwest_response_success() {
368        let auth = AuthResponse::default();
369        let http_response = Builder::new()
370            .status(201)
371            .header("content-type", "application/json")
372            .header("x-subject-token", "foobar")
373            .body(to_string(&auth).unwrap())
374            .unwrap();
375
376        let response: Response = Response::from(http_response);
377
378        let rsp = AuthToken::from_reqwest_response(response).await;
379        match rsp {
380            Ok(rsp) => {
381                assert_eq!(auth, rsp.auth_info.unwrap());
382                assert_eq!("foobar", rsp.token.expose_secret());
383            }
384            other => {
385                panic!("wrong response: {:?}", other);
386            }
387        }
388    }
389
390    #[tokio::test]
391    async fn test_from_reqwest_response_success_no_token() {
392        let auth = AuthResponse::default();
393        let http_response = Builder::new()
394            .status(201)
395            .header("content-type", "application/json")
396            .body(to_string(&auth).unwrap())
397            .unwrap();
398
399        let response: Response = Response::from(http_response);
400
401        let rsp = AuthToken::from_reqwest_response(response).await;
402        match rsp {
403            Err(AuthError::AuthTokenNotInResponse) => {}
404            other => {
405                panic!("wrong response: {:?}", other);
406            }
407        }
408    }
409}