Skip to content

Commit

Permalink
Merge pull request #76 from milliams/app_cred
Browse files Browse the repository at this point in the history
Add support for Application Credential authentication
  • Loading branch information
dtantsur authored Oct 7, 2023
2 parents a2bf0eb + 199b6fa commit e962d25
Show file tree
Hide file tree
Showing 6 changed files with 314 additions and 2 deletions.
161 changes: 161 additions & 0 deletions src/identity/application_credential.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
// Copyright 2023 Matt Williams <[email protected]>
//
// 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",
/// "<a cred id>",
/// "<a cred secret>",
/// )
/// .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<U, S1, S2>(auth_url: U, id: S1, secret: S2) -> Result<Self, Error>
where
U: AsRef<str>,
S1: Into<String>,
S2: Into<String>,
{
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<U, S1, S2, S3>(
auth_url: U,
name: S1,
secret: S2,
user_id: S3,
) -> Result<Self, Error>
where
U: AsRef<str>,
S1: Into<String>,
S2: Into<String>,
S3: Into<String>,
{
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<RequestBuilder, Error> {
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<Url, Error> {
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");
}
}
4 changes: 3 additions & 1 deletion src/identity/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,18 @@

//! Authentication using Identity API v3.
//!
//! Currently only supports [Password](struct.Password.html) authentication.
//! Currently supports [Password](struct.Password.html) and [ApplicationCredential] authentication.
//! Identity API v2 is not and will not be supported.
mod application_credential;
mod internal;
mod password;
pub(crate) mod protocol;
mod token;

use super::common::IdOrName;

pub use self::application_credential::ApplicationCredential;
pub use self::password::Password;
pub use self::token::Token;

Expand Down
79 changes: 79 additions & 0 deletions src/identity/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,25 @@ pub struct UserAndPassword {
pub domain: Option<IdOrName>,
}

/// 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<IdOrName>,
}

/// Authentication identity.
#[derive(Clone, Debug)]
pub enum Identity {
/// Authentication with a user and a password.
Password(UserAndPassword),
/// Authentication with a token.
Token(String),
/// Authentication with an application credential.
ApplicationCredential(ApplicationCredential),
}

/// A reference to a project in a domain.
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}
}
44 changes: 43 additions & 1 deletion src/loading/cloud.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -52,6 +52,14 @@ pub(crate) struct Auth {
pub(crate) username: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) user_domain_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) user_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) application_credential_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) application_credential_secret: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) application_credential_name: Option<String>,
}

/// Cloud configuration.
Expand Down Expand Up @@ -169,6 +177,38 @@ impl Auth {
Ok(id)
}

fn create_application_credential(self) -> Result<ApplicationCredential, Error> {
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<String>) -> Result<Arc<dyn AuthType>, Error> {
let auth_type = auth_type.unwrap_or_else(|| {
if self.token.is_some() {
Expand All @@ -185,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 {
Expand Down
24 changes: 24 additions & 0 deletions src/loading/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading

0 comments on commit e962d25

Please sign in to comment.