use std::collections::HashMap;
use std::fmt::{self, Debug};
use std::time::SystemTime;
use async_trait::async_trait;
use http::{HeaderMap, HeaderValue};
use reqwest::{Client, Request, Response};
use secrecy::SecretString;
use thiserror::Error;
use tracing::{Level, event, instrument};
pub mod auth_helper;
pub mod authtoken;
pub mod authtoken_scope;
pub mod types;
pub use authtoken::{AuthToken, AuthTokenError};
pub use authtoken_scope::AuthTokenScope;
pub use types::*;
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum AuthError {
#[error("authentication rejected")]
AuthReceipt(Box<AuthReceiptResponse>),
#[error("authentication receipt cannot be converted to string")]
AuthReceiptNotString,
#[error("AuthToken error: {}", source)]
AuthToken {
#[from]
source: AuthTokenError,
},
#[error("value necessary for the chosen auth method was not supplied to the auth method")]
AuthValueNotSupplied(String),
#[error("authentication method error: {}", .0.message)]
Identity(IdentityError),
#[error("plugin specified malformed requirements")]
PluginMalformedRequirement,
#[error("failed to deserialize response body: {}", source)]
Serde {
#[from]
source: serde_json::Error,
},
#[error("header value error: {}", source)]
HeaderValue {
#[from]
source: http::header::InvalidHeaderValue,
},
#[error("plugin error: {}", source)]
Plugin {
#[source]
source: Box<dyn std::error::Error + Send + Sync + 'static>,
},
#[error(transparent)]
Reqwest {
#[from]
source: reqwest::Error,
},
#[error("identity service error")]
UnknownAuth {
code: u16,
message: Option<String>,
},
#[error(transparent)]
Url {
#[from]
source: url::ParseError,
},
}
impl AuthError {
pub fn plugin<E>(error: E) -> Self
where
E: std::error::Error + Send + Sync + 'static,
{
Self::Plugin {
source: Box::new(error),
}
}
}
#[async_trait]
pub trait OpenStackAuthType: Send + Sync {
fn get_supported_auth_methods(&self) -> Vec<&'static str>;
fn requirements(
&self,
hints: Option<&serde_json::Value>,
) -> Result<serde_json::Value, AuthError>;
fn api_version(&self) -> (u8, u8);
async fn auth(
&self,
http_client: &reqwest::Client,
identity_url: &url::Url,
values: &HashMap<String, SecretString>,
scope: Option<&AuthTokenScope>,
hints: Option<&serde_json::Value>,
) -> Result<Auth, AuthError>;
}
pub struct AuthPluginRegistration {
pub method: &'static dyn OpenStackAuthType,
}
inventory::collect!(AuthPluginRegistration);
pub trait OpenStackMultifactorAuthMethod: Send + Sync {
fn get_supported_auth_methods(&self) -> Vec<&'static str>;
fn requirements(
&self,
hints: Option<&serde_json::Value>,
) -> Result<serde_json::Value, AuthError>;
fn get_auth_data(
&self,
values: &HashMap<String, SecretString>,
) -> Result<(&'static str, serde_json::Value), AuthError>;
}
pub struct AuthMethodPluginRegistration {
pub method: &'static dyn OpenStackMultifactorAuthMethod,
}
inventory::collect!(AuthMethodPluginRegistration);
#[instrument(name="request", skip_all, fields(http.uri = request.url().as_str(), http.method = request.method().as_str(), openstack.ver=request.headers().get("openstack-api-version").map(|v| v.to_str().unwrap_or(""))))]
pub async fn execute_auth_request(
client: &Client,
request: Request,
) -> Result<Response, reqwest::Error> {
let url = request.url().clone();
let method = request.method().clone();
let start = SystemTime::now();
let rsp = client.execute(request).await?;
let elapsed = SystemTime::now().duration_since(start).unwrap_or_default();
event!(
name: "http_request",
Level::INFO,
url=url.as_str(),
duration_ms=elapsed.as_millis(),
status=rsp.status().as_u16(),
method=method.as_str(),
request_id=rsp.headers().get("x-openstack-request-id").map(|v| v.to_str().unwrap_or("")),
"Request completed with status {}",
rsp.status(),
);
Ok(rsp)
}
#[inline(never)]
pub fn anchor_plugins() {
let auth_methods: Vec<&str> = inventory::iter::<AuthPluginRegistration>
.into_iter()
.flat_map(|x| x.method.get_supported_auth_methods())
.collect();
let multifactor_methods: Vec<&str> = inventory::iter::<AuthMethodPluginRegistration>
.into_iter()
.flat_map(|x| x.method.get_supported_auth_methods())
.collect();
tracing::debug!(
"Discovered auth plugins: {:?}; multifactor auth plugins: {:?}",
auth_methods,
multifactor_methods
);
}
#[derive(Clone)]
#[non_exhaustive]
pub enum Auth {
AuthToken(Box<AuthToken>),
None,
}
impl Auth {
pub fn set_header(&self, headers: &mut HeaderMap<HeaderValue>) -> Result<(), AuthError> {
if let Auth::AuthToken(token) = self {
token.set_header(headers)?;
}
Ok(())
}
}
impl fmt::Debug for Auth {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"Auth {}",
match self {
Auth::AuthToken(_) => "Token",
Auth::None => "unauthed",
}
)
}
}
impl TryFrom<http::Response<bytes::Bytes>> for Auth {
type Error = AuthError;
fn try_from(value: http::Response<bytes::Bytes>) -> Result<Self, Self::Error> {
Ok(Self::AuthToken(Box::new(AuthToken::try_from(value)?)))
}
}
#[derive(Debug, Eq, PartialEq)]
pub enum AuthState {
Valid,
Expired,
AboutToExpire,
Unset,
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum BuilderError {
#[error("{0}")]
UninitializedField(String),
#[error("{0}")]
Validation(String),
}
impl From<String> for BuilderError {
fn from(s: String) -> Self {
Self::Validation(s)
}
}
impl From<derive_builder::UninitializedFieldError> for BuilderError {
fn from(ufe: derive_builder::UninitializedFieldError) -> Self {
Self::UninitializedField(ufe.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{AuthResponse, TokenInfo};
use std::hash::{Hash, Hasher};
#[test]
fn test_auth_validity_unset() {
let auth = super::AuthToken::default();
assert!(matches!(auth.get_state(None), AuthState::Unset));
}
#[test]
fn test_auth_validity_expired() {
let auth = super::AuthToken::new(
String::new(),
Some(AuthResponse {
token: TokenInfo {
expires_at: chrono::Utc::now() - chrono::TimeDelta::days(1),
..Default::default()
},
}),
);
assert!(matches!(auth.get_state(None), AuthState::Expired));
}
#[test]
fn test_auth_validity_expire_soon() {
let auth = super::AuthToken::new(
String::new(),
Some(AuthResponse {
token: TokenInfo {
expires_at: chrono::Utc::now() + chrono::TimeDelta::minutes(10),
..Default::default()
},
}),
);
assert!(matches!(
auth.get_state(Some(chrono::TimeDelta::minutes(15))),
AuthState::AboutToExpire
));
}
#[test]
fn test_auth_validity_valid() {
let auth = super::AuthToken::new(
String::new(),
Some(AuthResponse {
token: TokenInfo {
expires_at: chrono::Utc::now() + chrono::TimeDelta::days(1),
..Default::default()
},
}),
);
assert!(matches!(auth.get_state(None), AuthState::Valid));
}
#[test]
fn test_auth_set_header_invalid_token() {
let auth = Auth::AuthToken(Box::new(super::AuthToken::new(
"invalid\nheader\nvalue",
None,
)));
let mut headers = HeaderMap::new();
let result = auth.set_header(&mut headers);
assert!(result.is_err());
}
#[test]
fn test_project_domain_eq_hash() {
use std::collections::HashSet;
let p1 = Project {
id: Some("1".into()),
name: Some("proj".into()),
domain: Some(Domain {
id: Some("d1".into()),
name: Some("D".into()),
}),
};
let p2 = Project {
id: Some("1".into()),
name: Some("proj".into()),
domain: Some(Domain {
id: Some("d1".into()),
name: Some("D".into()),
}),
};
assert_eq!(p1, p2);
let h1 = {
let mut h = std::collections::hash_map::DefaultHasher::new();
p1.hash(&mut h);
h.finish()
};
let h2 = {
let mut h = std::collections::hash_map::DefaultHasher::new();
p2.hash(&mut h);
h.finish()
};
assert_eq!(h1, h2, "equal projects must have equal hashes");
let mut pset = HashSet::new();
pset.insert(p1.clone());
assert!(pset.contains(&p2), "HashSet should contain equal project");
}
#[test]
fn test_project_both_name_and_domain_none() {
let p1 = Project {
id: Some("x".into()),
name: None,
domain: None,
};
let p2 = Project {
id: Some("y".into()),
name: None,
domain: None,
};
assert_ne!(p1, p2);
}
#[test]
fn test_project_domain_none_vs_some() {
let p1 = Project {
id: None,
name: Some("p".into()),
domain: None,
};
let p2 = Project {
id: None,
name: Some("p".into()),
domain: Some(Domain {
id: None,
name: None,
}),
};
assert_ne!(p1, p2);
}
#[test]
fn test_domain_both_names_none() {
let d1 = Domain {
id: Some("x".into()),
name: None,
};
let d2 = Domain {
id: Some("y".into()),
name: None,
};
assert_ne!(d1, d2);
}
#[test]
fn test_domain_none_vs_some_name() {
let d1 = Domain {
id: None,
name: None,
};
let d2 = Domain {
id: None,
name: Some("D".into()),
};
assert_ne!(d1, d2);
}
#[test]
fn test_project_hashset_dedup() {
use std::collections::HashSet;
let d = Domain {
id: Some("d".into()),
name: Some("D".into()),
};
let p1 = Project {
id: Some("1".into()),
name: Some("p".into()),
domain: Some(d.clone()),
};
let p2 = Project {
id: Some("1".into()),
name: Some("p".into()),
domain: Some(d.clone()),
};
let p3 = Project {
id: Some("1".into()),
name: Some("p".into()),
domain: Some(d),
};
let mut set = HashSet::new();
set.insert(p1);
set.insert(p2);
set.insert(p3);
assert_eq!(
set.len(),
1,
"HashSet should deduplicate identical projects"
);
}
#[test]
fn test_auth_scope_from_response_project() {
let response = AuthResponse {
token: TokenInfo {
project: Some(Project {
id: Some("1".into()),
name: Some("p".into()),
domain: None,
}),
..Default::default()
},
};
let scope: AuthTokenScope = (&response).into();
assert!(matches!(scope, AuthTokenScope::Project(_)));
}
#[test]
fn test_auth_scope_from_response_domain() {
let response = AuthResponse {
token: TokenInfo {
project: None,
domain: Some(Domain {
id: Some("1".into()),
name: Some("D".into()),
}),
..Default::default()
},
};
let scope: AuthTokenScope = (&response).into();
assert!(matches!(scope, AuthTokenScope::Domain(_)));
}
#[test]
fn test_auth_scope_from_response_system() {
let response = AuthResponse {
token: TokenInfo {
project: None,
domain: None,
system: Some(System { all: Some(true) }),
..Default::default()
},
};
let scope: AuthTokenScope = (&response).into();
assert!(matches!(scope, AuthTokenScope::System(_)));
}
#[test]
fn test_auth_scope_from_response_unscoped() {
let response = AuthResponse {
token: TokenInfo {
project: None,
domain: None,
system: None,
..Default::default()
},
};
let scope: AuthTokenScope = (&response).into();
assert!(matches!(scope, AuthTokenScope::Unscoped));
}
#[test]
fn test_auth_scope_priority_project_over_domain() {
let response = AuthResponse {
token: TokenInfo {
project: Some(Project {
id: Some("1".into()),
name: Some("p".into()),
domain: None,
}),
domain: Some(Domain {
id: Some("1".into()),
name: Some("D".into()),
}),
..Default::default()
},
};
let scope: AuthTokenScope = (&response).into();
assert!(matches!(scope, AuthTokenScope::Project(_)));
}
#[test]
fn test_auth_scope_priority_domain_over_system() {
let response = AuthResponse {
token: TokenInfo {
project: None,
domain: Some(Domain {
id: Some("1".into()),
name: Some("D".into()),
}),
system: Some(System { all: Some(true) }),
..Default::default()
},
};
let scope: AuthTokenScope = (&response).into();
assert!(matches!(scope, AuthTokenScope::Domain(_)));
}
#[test]
fn test_auth_none_set_header_noop() {
let auth = Auth::None;
let mut headers = HeaderMap::new();
let result = auth.set_header(&mut headers);
assert!(result.is_ok());
assert!(headers.is_empty());
}
#[test]
fn test_auth_token_set_header() {
let auth = Auth::AuthToken(Box::new(super::AuthToken::new("my-token", None)));
let mut headers = HeaderMap::new();
auth.set_header(&mut headers).unwrap();
assert!(headers.contains_key("X-Auth-Token"));
}
#[test]
fn test_auth_debug_token() {
let auth = Auth::AuthToken(Box::new(super::AuthToken::new("tok", None)));
let debug = format!("{:?}", auth);
assert!(debug.contains("Token"));
}
#[test]
fn test_auth_debug_none() {
let auth = Auth::None;
let debug = format!("{:?}", auth);
assert!(debug.contains("unauthed"));
}
#[test]
fn test_try_from_http_response_for_auth() {
let auth = AuthResponse::default();
let json = serde_json::to_string(&auth).unwrap();
let body: bytes::Bytes = json.into_bytes().into();
let http_response = http::Response::builder()
.header("x-subject-token", "tok")
.body(body)
.unwrap();
let result = Auth::try_from(http_response);
assert!(result.is_ok());
assert!(matches!(result.unwrap(), Auth::AuthToken(_)));
}
#[test]
fn test_auth_error_plugin_constructor() {
#[derive(Debug)]
struct MyErr(&'static str);
impl std::fmt::Display for MyErr {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::error::Error for MyErr {}
let err = AuthError::plugin(MyErr("plug"));
assert!(matches!(err, AuthError::Plugin { .. }));
assert!(format!("{}", err).contains("plug"));
}
#[test]
fn test_auth_error_display_unknown_auth_with_message() {
let err = AuthError::UnknownAuth {
code: 500,
message: Some("upstream timeout".into()),
};
assert_eq!(format!("{}", err), "identity service error");
}
#[test]
fn test_auth_error_display_unknown_auth_without_message() {
let err = AuthError::UnknownAuth {
code: 502,
message: None,
};
assert_eq!(format!("{}", err), "identity service error");
}
#[test]
fn test_auth_error_display_auth_value_not_supplied() {
let err = AuthError::AuthValueNotSupplied("user".to_string());
assert!(format!("{}", err).contains("value necessary"));
}
#[test]
fn test_builder_error_from_string() {
let err: BuilderError = "custom validation".to_string().into();
assert!(matches!(err, BuilderError::Validation(_)));
assert!(format!("{}", err).contains("custom validation"));
}
#[test]
fn test_auth_token_scope_default() {
let scope = AuthTokenScope::default();
assert!(matches!(scope, AuthTokenScope::Unscoped));
}
#[test]
fn test_auth_receipt_not_string_display() {
let err = AuthError::AuthReceiptNotString;
assert!(format!("{}", err).contains("receipt"));
}
#[test]
fn test_auth_error_identity_display() {
let err = AuthError::Identity(IdentityError {
code: 401,
message: "unauthorized".into(),
});
assert!(format!("{}", err).contains("authentication method error"));
assert!(format!("{}", err).contains("unauthorized"));
}
#[test]
fn test_name_or_id_serialize() {
let id = NameOrId::Id("abc".into());
let json = serde_json::to_string(&id).unwrap();
assert!(json.contains("id"));
assert!(json.contains("abc"));
let name = NameOrId::Name("myproj".into());
let json = serde_json::to_string(&name).unwrap();
assert!(json.contains("name"));
assert!(json.contains("myproj"));
}
#[test]
fn test_name_or_id_deserialize() {
let json = r#"{"id": "abc"}"#;
let parsed: NameOrId = serde_json::from_str(json).unwrap();
assert!(matches!(parsed, NameOrId::Id(ref s) if s == "abc"));
let json = r#"{"name": "myproj"}"#;
let parsed: NameOrId = serde_json::from_str(json).unwrap();
assert!(matches!(parsed, NameOrId::Name(ref s) if s == "myproj"));
}
#[test]
fn test_project_serialize_human_readable_skips_none() {
let p = Project {
id: Some("1".into()),
name: None,
domain: None,
};
let json = serde_json::to_string(&p).unwrap();
assert!(json.contains("id"));
}
#[test]
fn test_domain_serialize_human_readable_skips_none() {
let d = Domain {
id: Some("1".into()),
name: None,
};
let json = serde_json::to_string(&d).unwrap();
assert!(json.contains("id"));
}
#[test]
fn test_project_serialize_all_fields() {
let d = Domain {
id: Some("d".into()),
name: Some("D".into()),
};
let p = Project {
id: Some("1".into()),
name: Some("p".into()),
domain: Some(d),
};
let json = serde_json::to_string(&p).unwrap();
assert!(json.contains("id"));
assert!(json.contains("name"));
assert!(json.contains("domain"));
}
#[test]
fn test_auth_state_debug() {
assert_eq!(format!("{:?}", AuthState::Valid), "Valid");
assert_eq!(format!("{:?}", AuthState::Expired), "Expired");
assert_eq!(format!("{:?}", AuthState::AboutToExpire), "AboutToExpire");
assert_eq!(format!("{:?}", AuthState::Unset), "Unset");
}
#[test]
fn test_auth_receipt_response_serialization() {
let receipt = AuthReceiptResponse {
receipt: AuthReceipt {
methods: vec!["password".to_string()],
user: User {
id: "u".to_string(),
name: "user".to_string(),
..Default::default()
},
expires_at: chrono::Local::now(),
..Default::default()
},
required_auth_methods: vec![vec!["totp".to_string()]],
token: Some("tok".to_string()),
};
let json = serde_json::to_string(&receipt).unwrap();
assert!(json.contains("receipt"));
assert!(json.contains("password"));
assert!(json.contains("totp"));
}
#[test]
fn test_anchor_plugins() {
anchor_plugins();
}
}