Skip to main content

openstack_sdk_auth_password/
lib.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//! # User password authentication method for [`openstack_sdk`]
16//!
17//! Authorization using Token look like:
18//!
19//! ```json
20//! {
21//!     "auth": {
22//!         "identity": {
23//!             "methods": [
24//!                 "password"
25//!             ],
26//!             "password": {
27//!                 "user": {
28//!                     "name": "admin",
29//!                     "domain": {
30//!                         "name": "Default"
31//!                     },
32//!                     "password": "devstacker"
33//!                 }
34//!             }
35//!         }
36//!     }
37//! }
38//! ```
39
40use std::collections::HashMap;
41
42use async_trait::async_trait;
43use thiserror::Error;
44
45use secrecy::{ExposeSecret, SecretString};
46use serde_json::{Value, json};
47
48use openstack_sdk_auth_core::{
49    Auth, AuthError, AuthMethodPluginRegistration, AuthPluginRegistration, AuthToken,
50    AuthTokenScope, OpenStackAuthType, OpenStackMultifactorAuthMethod, execute_auth_request,
51};
52
53/// Token Authentication for OpenStack SDK.
54pub struct PasswordAuthenticator;
55
56// Submit the plugin to the registry at compile-time
57static PLUGIN: PasswordAuthenticator = PasswordAuthenticator;
58inventory::submit! {
59    AuthPluginRegistration { method: &PLUGIN }
60}
61inventory::submit! {
62    AuthMethodPluginRegistration { method: &PLUGIN }
63}
64
65impl PasswordAuthenticator {
66    fn _get_supported_auth_methods(&self) -> Vec<&'static str> {
67        vec!["v3password", "password"]
68    }
69
70    fn _requirements(&self) -> Value {
71        json!({
72            "type": "object",
73            "required": ["password"],
74            "properties": {
75                "password": {
76                    "type": "string",
77                    "format": "password",
78                    "description": "User password",
79                },
80                "user_id": {
81                    "type": "string",
82                    "description": "User ID",
83                },
84                "username": {
85                    "type": "string",
86                    "description": "User name",
87                },
88                "user_domain_id": {
89                    "type": "string",
90                    "description": "User domain ID",
91                },
92                "user_domain_name": {
93                    "type": "string",
94                    "description": "User domain name",
95                },
96            }
97        })
98    }
99
100    fn _get_auth_data(
101        &self,
102        values: &HashMap<String, SecretString>,
103    ) -> Result<(&'static str, Value), PasswordAuthError> {
104        let password = values
105            .get("password")
106            .ok_or(PasswordAuthError::MissingPassword)?
107            .expose_secret();
108
109        let mut user = json!({"password": password});
110        if let Some(user_id) = values.get("user_id") {
111            user["id"] = user_id.expose_secret().into();
112        } else if let Some(user_name) = values.get("username") {
113            user["name"] = user_name.expose_secret().into();
114            if let Some(udi) = values.get("user_domain_id") {
115                user["domain"]["id"] = udi.expose_secret().into();
116            } else if let Some(udn) = values.get("user_domain_name") {
117                user["domain"]["name"] = udn.expose_secret().into();
118            } else {
119                return Err(PasswordAuthError::MissingUserDomain)?;
120            }
121        } else {
122            return Err(PasswordAuthError::MissingUser)?;
123        }
124        let body = json!({ "password": {"user": user } });
125        Ok(("password", body))
126    }
127}
128
129impl OpenStackMultifactorAuthMethod for PasswordAuthenticator {
130    /// Return list of supported authentication methods.
131    fn get_supported_auth_methods(&self) -> Vec<&'static str> {
132        self._get_supported_auth_methods()
133    }
134
135    /// Get the json schema of the data the plugin requires to complete the authentication.
136    fn requirements(
137        &self,
138        _hints: Option<&serde_json::Value>,
139    ) -> Result<serde_json::Value, AuthError> {
140        Ok(self._requirements())
141    }
142
143    /// Authenticate the client with the configuration.
144    fn get_auth_data(
145        &self,
146        values: &HashMap<String, SecretString>,
147    ) -> Result<(&'static str, serde_json::Value), AuthError> {
148        Ok(self._get_auth_data(values)?)
149    }
150}
151
152#[async_trait]
153impl OpenStackAuthType for PasswordAuthenticator {
154    fn get_supported_auth_methods(&self) -> Vec<&'static str> {
155        self._get_supported_auth_methods()
156    }
157
158    fn requirements(&self, _hints: Option<&Value>) -> Result<Value, AuthError> {
159        Ok(self._requirements())
160    }
161
162    fn api_version(&self) -> (u8, u8) {
163        (3, 0)
164    }
165
166    async fn auth(
167        &self,
168        http_client: &reqwest::Client,
169        identity_url: &url::Url,
170        values: HashMap<String, SecretString>,
171        scope: Option<&AuthTokenScope>,
172        _hints: Option<&serde_json::Value>,
173    ) -> Result<Auth, AuthError> {
174        let (method, data) = self._get_auth_data(&values)?;
175        let mut body = json!({ "auth": { "identity": data } });
176        body["auth"]["identity"]["methods"] = [method].into();
177        if let Some(scope) = scope {
178            body["auth"]["scope"] = serde_json::to_value(scope)?;
179        }
180
181        let endpoint = identity_url.join("auth/tokens")?;
182
183        let request = http_client.post(endpoint).json(&body).build()?;
184        let response = execute_auth_request(http_client, request).await?;
185
186        let auth_token = AuthToken::from_reqwest_response(response).await?;
187
188        Ok(Auth::AuthToken(Box::new(auth_token)))
189    }
190}
191
192/// Token related errors
193#[derive(Debug, Error)]
194#[non_exhaustive]
195pub enum PasswordAuthError {
196    /// Missing user password.
197    #[error("user password is missing")]
198    MissingPassword,
199
200    /// Missing User info.
201    #[error("User name/id is required")]
202    MissingUser,
203
204    /// Missing User info.
205    #[error("User domain name/id is required")]
206    MissingUserDomain,
207}
208
209impl From<PasswordAuthError> for AuthError {
210    fn from(source: PasswordAuthError) -> Self {
211        Self::plugin(source)
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use httpmock::MockServer;
218    use reqwest::Client;
219    use reqwest::StatusCode;
220    use secrecy::SecretString;
221    use serde_json::json;
222    use std::collections::HashMap;
223    use url::Url;
224
225    use openstack_sdk_auth_core::Auth;
226    use openstack_sdk_auth_core::types::*;
227
228    use super::*;
229
230    #[test]
231    fn test_get_supported_auth_methods() {
232        let authenticator = &PLUGIN;
233        assert!(
234            openstack_sdk_auth_core::OpenStackAuthType::get_supported_auth_methods(authenticator)
235                .contains(&"v3password")
236        );
237        assert!(
238            openstack_sdk_auth_core::OpenStackAuthType::get_supported_auth_methods(authenticator)
239                .contains(&"password")
240        );
241    }
242
243    #[test]
244    fn test_requirements() {
245        let authenticator = &PLUGIN;
246        assert!(
247            openstack_sdk_auth_core::OpenStackAuthType::requirements(authenticator, None).is_ok(),
248        );
249    }
250
251    #[test]
252    fn test_get_auth_data() {
253        let authenticator = &PLUGIN;
254        assert_eq!(
255            (
256                "password",
257                json!({"password": {"user": {"id": "uid", "password": "password"}}})
258            ),
259            authenticator
260                .get_auth_data(&HashMap::from([
261                    ("password".into(), SecretString::from("password")),
262                    ("user_id".into(), SecretString::from("uid")),
263                ]))
264                .unwrap()
265        );
266        assert!(
267            authenticator
268                .get_auth_data(&HashMap::from([
269                    ("password".into(), SecretString::from("password")),
270                    ("username".into(), SecretString::from("uname")),
271                ]))
272                .is_err()
273        );
274        assert_eq!(
275            (
276                "password",
277                json!({"password": {"user": {"name": "uname", "password": "password", "domain": {"name": "udname"}}}})
278            ),
279            authenticator
280                .get_auth_data(&HashMap::from([
281                    ("password".into(), SecretString::from("password")),
282                    ("username".into(), SecretString::from("uname")),
283                    ("user_domain_name".into(), SecretString::from("udname")),
284                ]))
285                .unwrap()
286        );
287        assert_eq!(
288            (
289                "password",
290                json!({"password": {"user": {"name": "uname", "password": "password", "domain": {"id": "udid"}}}})
291            ),
292            authenticator
293                .get_auth_data(&HashMap::from([
294                    ("password".into(), SecretString::from("password")),
295                    ("username".into(), SecretString::from("uname")),
296                    ("user_domain_id".into(), SecretString::from("udid")),
297                ]))
298                .unwrap()
299        );
300        assert_eq!(
301            (
302                "password",
303                json!({"password": {"user": {"name": "uname", "password": "password", "domain": {"id": "udid"}}}})
304            ),
305            authenticator
306                .get_auth_data(&HashMap::from([
307                    ("password".into(), SecretString::from("password")),
308                    ("username".into(), SecretString::from("uname")),
309                    ("user_domain_id".into(), SecretString::from("udid")),
310                    ("user_domain_name".into(), SecretString::from("udname")),
311                ]))
312                .unwrap()
313        );
314    }
315
316    #[tokio::test]
317    async fn test_auth() {
318        let server = MockServer::start_async().await;
319        let base_url = Url::parse(&server.base_url()).unwrap();
320
321        let mock = server
322            .mock_async(|when, then| {
323                when.method(httpmock::Method::POST)
324                    .path("/auth/tokens")
325                    .json_body(json!({
326                        "auth": {
327                            "identity": {
328                                "methods": ["password"],
329                                "password": {
330                                    "user": {
331                                        "id": "uid",
332                                        "password": "password"
333                                    }
334                                }
335                            }
336                        }
337                    }));
338                then.status(StatusCode::CREATED)
339                    .header("x-subject-token", "foo")
340                    .json_body(json!({
341                        "token": {
342                            "user": {
343                                "id": "uid",
344                                "name": "uname"
345                            },
346                            "expires_at": "2018-01-15T22:14:05.000000Z",
347                        }
348                    }));
349            })
350            .await;
351        let http_client = Client::new();
352
353        let authenticator = &PLUGIN;
354
355        match authenticator
356            .auth(
357                &http_client,
358                &base_url,
359                HashMap::from([
360                    ("password".into(), SecretString::from("password")),
361                    ("user_id".into(), SecretString::from("uid")),
362                ]),
363                None,
364                None,
365            )
366            .await
367        {
368            Ok(Auth::AuthToken(token)) => {
369                assert_eq!(token.token.expose_secret(), "foo");
370            }
371
372            other => {
373                panic!("success was expected, instead it is {:?}", other);
374            }
375        }
376        mock.assert_async().await;
377    }
378
379    #[tokio::test]
380    async fn test_auth_scope() {
381        let server = MockServer::start_async().await;
382        let base_url = Url::parse(&server.base_url()).unwrap();
383
384        let mock = server
385            .mock_async(|when, then| {
386                when.method(httpmock::Method::POST)
387                    .path("/auth/tokens")
388                    .json_body(json!({
389                        "auth": {
390                            "identity": {
391                                "methods": ["password"],
392                                "password": {
393                                    "user": {
394                                        "id": "uid",
395                                        "password": "password"
396                                    }
397                                }
398                            },
399                            "scope": {
400                                "project": {
401                                    "id": "pid"
402                                }
403                            }
404                        }
405                    }));
406                then.status(StatusCode::CREATED)
407                    .header("x-subject-token", "foo")
408                    .json_body(json!({
409                        "token": {
410                            "user": {
411                                "id": "uid",
412                                "name": "uname"
413                            },
414                            "expires_at": "2018-01-15T22:14:05.000000Z",
415                        }
416                    }));
417            })
418            .await;
419        let http_client = Client::new();
420
421        let authenticator = &PLUGIN;
422
423        match authenticator
424            .auth(
425                &http_client,
426                &base_url,
427                HashMap::from([
428                    ("password".into(), SecretString::from("password")),
429                    ("user_id".into(), SecretString::from("uid")),
430                ]),
431                Some(&AuthTokenScope::Project(Project {
432                    id: Some("pid".into()),
433                    ..Default::default()
434                })),
435                None,
436            )
437            .await
438        {
439            Ok(Auth::AuthToken(token)) => {
440                assert_eq!(token.token.expose_secret(), "foo");
441            }
442
443            other => {
444                panic!("success was expected, instead it is {:?}", other);
445            }
446        }
447        mock.assert_async().await;
448    }
449}