From 450f4a27f7e89204b42e64093d2ba46094e5aa67 Mon Sep 17 00:00:00 2001 From: Matt Williams Date: Mon, 18 Sep 2023 19:28:37 +0100 Subject: [PATCH] Add support for Application Credential authentication --- src/identity/application_credential.rs | 161 +++++++++++++++++++++++++ src/identity/mod.rs | 2 + src/identity/protocol.rs | 79 ++++++++++++ src/loading/cloud.rs | 42 ++++++- src/loading/config.rs | 24 ++++ src/loading/env.rs | 3 + 6 files changed, 310 insertions(+), 1 deletion(-) create mode 100644 src/identity/application_credential.rs diff --git a/src/identity/application_credential.rs b/src/identity/application_credential.rs new file mode 100644 index 0000000..eb6de12 --- /dev/null +++ b/src/identity/application_credential.rs @@ -0,0 +1,161 @@ +// Copyright 2023 Matt Williams +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Application Credential authentication. + +use async_trait::async_trait; +use reqwest::{Client, RequestBuilder, Url}; +use static_assertions::assert_impl_all; + +use super::internal::Internal; +use super::protocol; +use super::IdOrName; +use crate::{AuthType, EndpointFilters, Error}; + +/// Application Credential authentication using Identity API V3. +/// +/// For any Identity authentication you need to know `auth_url`, which is an authentication endpoint +/// of the Identity service. For the Application Credential authentication you also need: +/// 1. Application Credential ID +/// 2. Application Credential secret +/// +/// Start with creating a `ApplicationCredential` object using [new](#method.new): +/// +/// ```rust,no_run +/// use osauth::common::IdOrName; +/// let auth = osauth::identity::ApplicationCredential::new( +/// "https://cloud.local/identity", +/// "", +/// "", +/// ) +/// .expect("Invalid auth_url"); +/// +/// let session = osauth::Session::new(auth); +/// ``` +/// +/// The authentication token is cached while it's still valid or until +/// [refresh](../trait.AuthType.html#tymethod.refresh) is called. +/// Clones of an `ApplicationCredential` also start with an empty cache. +#[derive(Debug, Clone)] +pub struct ApplicationCredential { + inner: Internal, +} + +assert_impl_all!(ApplicationCredential: Send, Sync); + +impl ApplicationCredential { + /// Create an application credential authentication. + pub fn new(auth_url: U, id: S1, secret: S2) -> Result + where + U: AsRef, + S1: Into, + S2: Into, + { + let app_cred = protocol::ApplicationCredential { + id: IdOrName::Id(id.into()), + secret: secret.into(), + user: None, + }; + let body = protocol::AuthRoot { + auth: protocol::Auth { + identity: protocol::Identity::ApplicationCredential(app_cred), + scope: None, + }, + }; + Ok(Self { + inner: Internal::new(auth_url.as_ref(), body)?, + }) + } + + /// Create an application credential authentication from a credential name. + pub fn with_user_id( + auth_url: U, + name: S1, + secret: S2, + user_id: S3, + ) -> Result + where + U: AsRef, + S1: Into, + S2: Into, + S3: Into, + { + let app_cred = protocol::ApplicationCredential { + id: IdOrName::Name(name.into()), + secret: secret.into(), + user: Some(IdOrName::Id(user_id.into())), + }; + let body = protocol::AuthRoot { + auth: protocol::Auth { + identity: protocol::Identity::ApplicationCredential(app_cred), + scope: None, + }, + }; + Ok(Self { + inner: Internal::new(auth_url.as_ref(), body)?, + }) + } + + /// Project name or ID (if project scoped). + #[inline] + pub fn project(&self) -> Option<&IdOrName> { + self.inner.project() + } +} + +#[async_trait] +impl AuthType for ApplicationCredential { + /// Authenticate a request. + async fn authenticate( + &self, + client: &Client, + request: RequestBuilder, + ) -> Result { + self.inner.authenticate(client, request).await + } + + /// Get a URL for the requested service. + async fn get_endpoint( + &self, + client: &Client, + service_type: &str, + filters: &EndpointFilters, + ) -> Result { + self.inner.get_endpoint(client, service_type, filters).await + } + + /// Refresh the cached token and service catalog. + async fn refresh(&self, client: &Client) -> Result<(), Error> { + self.inner.refresh(client, true).await + } +} + +#[cfg(test)] +pub mod test { + #![allow(unused_results)] + + use reqwest::Url; + + use super::ApplicationCredential; + + #[test] + fn test_identity_new() { + let id = ApplicationCredential::new("http://127.0.0.1:8080/", "abcdef", "shhhh").unwrap(); + let e = Url::parse(id.inner.token_endpoint()).unwrap(); + assert_eq!(e.scheme(), "http"); + assert_eq!(e.host_str().unwrap(), "127.0.0.1"); + assert_eq!(e.port().unwrap(), 8080u16); + assert_eq!(e.path(), "/v3/auth/tokens"); + } +} diff --git a/src/identity/mod.rs b/src/identity/mod.rs index de16743..ffe7ea4 100644 --- a/src/identity/mod.rs +++ b/src/identity/mod.rs @@ -17,6 +17,7 @@ //! Currently only supports [Password](struct.Password.html) authentication. //! Identity API v2 is not and will not be supported. +mod application_credential; mod internal; mod password; pub(crate) mod protocol; @@ -24,6 +25,7 @@ mod token; use super::common::IdOrName; +pub use self::application_credential::ApplicationCredential; pub use self::password::Password; pub use self::token::Token; diff --git a/src/identity/protocol.rs b/src/identity/protocol.rs index 1e7effd..69a002e 100644 --- a/src/identity/protocol.rs +++ b/src/identity/protocol.rs @@ -30,6 +30,16 @@ pub struct UserAndPassword { pub domain: Option, } +/// Application credential. +#[derive(Clone, Debug, Serialize)] +pub struct ApplicationCredential { + #[serde(flatten)] + pub id: IdOrName, + pub secret: String, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub user: Option, +} + /// Authentication identity. #[derive(Clone, Debug)] pub enum Identity { @@ -37,6 +47,8 @@ pub enum Identity { Password(UserAndPassword), /// Authentication with a token. Token(String), + /// Authentication with an application credential. + ApplicationCredential(ApplicationCredential), } /// A reference to a project in a domain. @@ -152,6 +164,10 @@ impl Serialize for Identity { inner.serialize_field("methods", &["token"])?; inner.serialize_field("token", &TokenAuth { id: token })?; } + Identity::ApplicationCredential(ref cred) => { + inner.serialize_field("methods", &["application_credential"])?; + inner.serialize_field("application_credential", &cred)?; + } } inner.end() } @@ -254,6 +270,39 @@ mod test { } }"#; + const APPLICATION_CREDENTIAL_ID: &str = r#" +{ + "auth": { + "identity": { + "methods": [ + "application_credential" + ], + "application_credential": { + "id": "abcdef", + "secret": "shhhh" + } + } + } +}"#; + + const APPLICATION_CREDENTIAL_NAME: &str = r#" +{ + "auth": { + "identity": { + "methods": [ + "application_credential" + ], + "application_credential": { + "name": "abcdef", + "secret": "shhhh", + "user": { + "id": "a6b3c6e7a6d" + } + } + } + } +}"#; + #[test] fn test_password_name_unscoped() { let value = AuthRoot { @@ -309,4 +358,34 @@ mod test { }; test::compare(TOKEN_SCOPED_WITH_NAME, value); } + + #[test] + fn test_application_credential_id() { + let value = AuthRoot { + auth: Auth { + identity: Identity::ApplicationCredential(ApplicationCredential { + id: IdOrName::Id("abcdef".to_string()), + secret: "shhhh".to_string(), + user: None, + }), + scope: None, + }, + }; + test::compare(APPLICATION_CREDENTIAL_ID, value); + } + + #[test] + fn test_application_credential_name() { + let value = AuthRoot { + auth: Auth { + identity: Identity::ApplicationCredential(ApplicationCredential { + id: IdOrName::Name("abcdef".to_string()), + secret: "shhhh".to_string(), + user: Some(IdOrName::Id("a6b3c6e7a6d".into())), + }), + scope: None, + }, + }; + test::compare(APPLICATION_CREDENTIAL_NAME, value); + } } diff --git a/src/loading/cloud.rs b/src/loading/cloud.rs index d4ed6a0..62733b0 100644 --- a/src/loading/cloud.rs +++ b/src/loading/cloud.rs @@ -26,7 +26,7 @@ use super::config::from_config; use super::env::from_env; use crate::client::AuthenticatedClient; use crate::common::IdOrName; -use crate::identity::{Password, Scope, Token}; +use crate::identity::{ApplicationCredential, Password, Scope, Token}; use crate::{AuthType, BasicAuth, Error, ErrorKind, InterfaceType, NoAuth, Session}; #[derive(Debug, Clone, Default, Deserialize, Serialize)] @@ -54,6 +54,12 @@ pub(crate) struct Auth { pub(crate) user_domain_name: Option, #[serde(skip_serializing_if = "Option::is_none")] pub(crate) user_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) application_credential_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) application_credential_secret: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) application_credential_name: Option, } /// Cloud configuration. @@ -171,6 +177,38 @@ impl Auth { Ok(id) } + fn create_application_credential(self) -> Result { + let auth_url = require( + self.auth_url, + "Password authentication requires an authentication URL", + )?; + let app_secret = require( + self.application_credential_secret, + "Application credential requires a secret", + )?; + + if let Some(app_id) = self.application_credential_id { + ApplicationCredential::new(auth_url, app_id, app_secret) + } else if let Some(app_name) = self.application_credential_name { + if self.username.is_some() && self.user_id.is_none() { + return Err(Error::new( + ErrorKind::InvalidConfig, + "Specifying Application Credential by name currently requires specifying the user ID", + )); + } + let user_id = require( + self.user_id, + "Application Credential authentication by name requires the user ID", + )?; + ApplicationCredential::with_user_id(auth_url, app_name, app_secret, user_id) + } else { + Err(Error::new( + ErrorKind::InvalidConfig, + "Application Credential requires an id or a name", + )) + } + } + fn create_auth(self, auth_type: Option) -> Result, Error> { let auth_type = auth_type.unwrap_or_else(|| { if self.token.is_some() { @@ -187,6 +225,8 @@ impl Auth { Arc::new(self.create_token_auth()?) } else if auth_type == "http_basic" { Arc::new(self.create_basic_auth()?) + } else if auth_type == "v3applicationcredential" { + Arc::new(self.create_application_credential()?) } else if auth_type == "none" { Arc::new(self.create_none_auth()?) } else { diff --git a/src/loading/config.rs b/src/loading/config.rs index 6571dd1..3930cad 100644 --- a/src/loading/config.rs +++ b/src/loading/config.rs @@ -385,6 +385,30 @@ hK9jLBzNvo8qzKqaGfnGieuLeXCqFDA= .unwrap(); } + #[test] + fn test_from_config_application_credential() { + let clouds = to_yaml( + r#"clouds: + cloud_name: + auth_type: v3applicationcredential + auth: + auth_url: http://url1 + application_credential_id: appid1 + application_credential_secret: appidsecret1 + region_name: region1"#, + ); + + let _ = from_files( + "cloud_name", + clouds, + with_one_key("public-clouds"), + with_one_key("clouds"), + ) + .unwrap() + .create_session_config() + .unwrap(); + } + #[test] fn test_inject_profiles_error() { let mut clouds_data = to_yaml( diff --git a/src/loading/env.rs b/src/loading/env.rs index e03f139..2b3d2d6 100644 --- a/src/loading/env.rs +++ b/src/loading/env.rs @@ -51,6 +51,9 @@ fn _from_env(env: E) -> Result { username: env.get("OS_USERNAME").ok(), user_domain_name: env.get("OS_USER_DOMAIN_NAME").ok(), user_id: env.get("OS_USER_ID").ok(), + application_credential_id: env.get("OS_APPLICATION_CREDENTIAL_ID").ok(), + application_credential_secret: env.get("OS_APPLICATION_CREDENTIAL_SECRET").ok(), + application_credential_name: env.get("OS_APPLICATION_CREDENTIAL_NAME").ok(), }; let config = CloudConfig {