| // Licensed to the Apache Software Foundation (ASF) under one |
| // or more contributor license agreements. See the NOTICE file |
| // distributed with this work for additional information |
| // regarding copyright ownership. The ASF licenses this file |
| // to you 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. |
| |
| //! DLF Authentication Provider for Alibaba Cloud Data Lake Formation. |
| |
| use std::collections::HashMap; |
| use std::sync::Arc; |
| |
| use async_trait::async_trait; |
| use chrono::Utc; |
| use reqwest::Client; |
| use serde::{Deserialize, Serialize}; |
| |
| use super::base::{AuthProvider, RESTAuthParameter, AUTHORIZATION_HEADER_KEY}; |
| use super::dlf_signer::{DLFRequestSigner, DLFSignerFactory}; |
| use crate::common::{CatalogOptions, Options}; |
| use crate::error::Error; |
| use crate::Result; |
| |
| // ============================================================================ |
| // DLF Token and Token Loader |
| // ============================================================================ |
| |
| /// DLF Token containing access credentials for Alibaba Cloud Data Lake Formation. |
| #[derive(Debug, Clone, Serialize, Deserialize)] |
| pub struct DLFToken { |
| /// Access key ID for Alibaba Cloud. |
| #[serde(rename = "AccessKeyId")] |
| pub access_key_id: String, |
| /// Access key secret for Alibaba Cloud. |
| #[serde(rename = "AccessKeySecret")] |
| pub access_key_secret: String, |
| /// Security token for temporary credentials (optional). |
| #[serde(rename = "SecurityToken")] |
| pub security_token: Option<String>, |
| /// Expiration timestamp in milliseconds. |
| #[serde(rename = "ExpirationAt", default, skip_serializing)] |
| pub expiration_at_millis: Option<i64>, |
| /// Expiration time string (ISO 8601 format). |
| #[serde( |
| rename = "Expiration", |
| default, |
| skip_serializing_if = "Option::is_none" |
| )] |
| pub expiration: Option<String>, |
| } |
| |
| impl DLFToken { |
| /// Token date format for parsing expiration. |
| const TOKEN_DATE_FORMAT: &'static str = "%Y-%m-%dT%H:%M:%SZ"; |
| |
| /// Create a new DLFToken. |
| /// |
| /// # Arguments |
| /// * `access_key_id` - The access key ID |
| /// * `access_key_secret` - The access key secret |
| /// * `security_token` - Optional security token |
| /// * `expiration` - Optional expiration time string (ISO 8601 format) |
| /// * `expiration_at_millis` - Optional expiration timestamp in milliseconds. |
| /// If provided, this value is used directly. Otherwise, it will be parsed from `expiration`. |
| pub fn new( |
| access_key_id: impl Into<String>, |
| access_key_secret: impl Into<String>, |
| security_token: Option<String>, |
| expiration_at_millis: Option<i64>, |
| expiration: Option<String>, |
| ) -> Self { |
| let access_key_id = access_key_id.into(); |
| let access_key_secret = access_key_secret.into(); |
| |
| // Use provided expiration_at_millis, or parse from expiration string if not provided |
| let expiration_at_millis = expiration_at_millis.or_else(|| { |
| expiration |
| .as_deref() |
| .and_then(Self::parse_expiration_to_millis) |
| }); |
| |
| Self { |
| access_key_id, |
| access_key_secret, |
| security_token, |
| expiration_at_millis, |
| expiration, |
| } |
| } |
| |
| /// Create a DLFToken from configuration options. |
| pub fn from_options(options: &Options) -> Option<Self> { |
| let access_key_id = options.get(CatalogOptions::DLF_ACCESS_KEY_ID)?.clone(); |
| let access_key_secret = options.get(CatalogOptions::DLF_ACCESS_KEY_SECRET)?.clone(); |
| let security_token = options |
| .get(CatalogOptions::DLF_ACCESS_SECURITY_TOKEN) |
| .cloned(); |
| |
| Some(Self::new( |
| access_key_id, |
| access_key_secret, |
| security_token, |
| None, |
| None, |
| )) |
| } |
| |
| /// Parse expiration string to milliseconds timestamp. |
| pub fn parse_expiration_to_millis(expiration: &str) -> Option<i64> { |
| let datetime = chrono::NaiveDateTime::parse_from_str(expiration, Self::TOKEN_DATE_FORMAT) |
| .ok()? |
| .and_utc(); |
| Some(datetime.timestamp_millis()) |
| } |
| } |
| /// Trait for DLF token loaders. |
| #[async_trait] |
| pub trait DLFTokenLoader: Send + Sync { |
| /// Load a DLF token. |
| async fn load_token(&self) -> Result<DLFToken>; |
| |
| /// Get a description of the loader. |
| fn description(&self) -> &str; |
| } |
| |
| /// DLF ECS Token Loader. |
| /// |
| /// Loads DLF tokens from ECS metadata service. |
| /// |
| /// This implementation mirrors the Python DLFECSTokenLoader class, |
| /// using class-level HTTP client for connection reuse and retry logic. |
| pub struct DLFECSTokenLoader { |
| ecs_metadata_url: String, |
| role_name: Option<String>, |
| http_client: TokenHTTPClient, |
| } |
| |
| impl DLFECSTokenLoader { |
| /// Create a new DLFECSTokenLoader. |
| /// |
| /// # Arguments |
| /// * `ecs_metadata_url` - ECS metadata service URL |
| /// * `role_name` - Optional role name. If None, will be fetched from metadata service |
| pub fn new(ecs_metadata_url: impl Into<String>, role_name: Option<String>) -> Self { |
| Self { |
| ecs_metadata_url: ecs_metadata_url.into(), |
| role_name, |
| http_client: TokenHTTPClient::new(), |
| } |
| } |
| |
| /// Get the role name from ECS metadata service. |
| async fn get_role(&self) -> Result<String> { |
| self.http_client.get(&self.ecs_metadata_url).await |
| } |
| |
| /// Get the token from ECS metadata service. |
| async fn get_token(&self, url: &str) -> Result<DLFToken> { |
| let token_json = self.http_client.get(url).await?; |
| serde_json::from_str(&token_json).map_err(|e| Error::DataInvalid { |
| message: format!("Failed to parse token JSON: {e}"), |
| source: None, |
| }) |
| } |
| |
| /// Build the token URL from base URL and role name. |
| fn build_token_url(&self, role_name: &str) -> String { |
| let base_url = self.ecs_metadata_url.trim_end_matches('/'); |
| format!("{base_url}/{role_name}") |
| } |
| } |
| |
| #[async_trait] |
| impl DLFTokenLoader for DLFECSTokenLoader { |
| async fn load_token(&self) -> Result<DLFToken> { |
| let role_name = match &self.role_name { |
| Some(name) => name.clone(), |
| None => { |
| // Fetch role name from metadata service |
| self.get_role().await? |
| } |
| }; |
| |
| // Build token URL |
| let token_url = self.build_token_url(&role_name); |
| |
| // Get token |
| self.get_token(&token_url).await |
| } |
| |
| fn description(&self) -> &str { |
| &self.ecs_metadata_url |
| } |
| } |
| /// Factory for creating DLF token loaders. |
| pub struct DLFTokenLoaderFactory; |
| |
| impl DLFTokenLoaderFactory { |
| /// Create a token loader based on options. |
| pub fn create_token_loader(options: &Options) -> Option<Arc<dyn DLFTokenLoader>> { |
| let loader = options.get(CatalogOptions::DLF_TOKEN_LOADER)?; |
| |
| if loader == "ecs" { |
| let ecs_metadata_url = options |
| .get(CatalogOptions::DLF_TOKEN_ECS_METADATA_URL) |
| .cloned() |
| .unwrap_or_else(|| { |
| "http://100.100.100.200/latest/meta-data/Ram/security-credentials/".to_string() |
| }); |
| let role_name = options |
| .get(CatalogOptions::DLF_TOKEN_ECS_ROLE_NAME) |
| .cloned(); |
| Some( |
| Arc::new(DLFECSTokenLoader::new(ecs_metadata_url, role_name)) |
| as Arc<dyn DLFTokenLoader>, |
| ) |
| } else { |
| None |
| } |
| } |
| } |
| // ============================================================================ |
| // DLF Auth Provider |
| // ============================================================================ |
| |
| /// Token expiration safe time in milliseconds (1 hour). |
| /// Token will be refreshed if it expires within this time. |
| const TOKEN_EXPIRATION_SAFE_TIME_MILLIS: i64 = 3_600_000; |
| |
| /// DLF Authentication Provider for Alibaba Cloud Data Lake Formation. |
| /// |
| /// This provider implements authentication for Alibaba Cloud DLF service, |
| /// supporting both VPC endpoints (DLF4-HMAC-SHA256) and public endpoints |
| /// (ROA v2 HMAC-SHA1). |
| pub struct DLFAuthProvider { |
| uri: String, |
| token: tokio::sync::Mutex<Option<DLFToken>>, |
| token_loader: Option<Arc<dyn DLFTokenLoader>>, |
| signer: Box<dyn DLFRequestSigner>, |
| } |
| |
| impl DLFAuthProvider { |
| /// Create a new DLFAuthProvider. |
| /// |
| /// # Arguments |
| /// * `uri` - The DLF service URI |
| /// * `token` - Optional DLF token containing access credentials |
| /// * `token_loader` - Optional token loader for dynamic token retrieval |
| /// |
| /// # Errors |
| /// Returns an error if both `token` and `token_loader` are `None`. |
| pub fn new( |
| uri: impl Into<String>, |
| region: impl Into<String>, |
| signing_algorithm: impl Into<String>, |
| token: Option<DLFToken>, |
| token_loader: Option<Arc<dyn DLFTokenLoader>>, |
| ) -> Result<Self> { |
| if token.is_none() && token_loader.is_none() { |
| return Err(Error::ConfigInvalid { |
| message: "Either token or token_loader must be provided".to_string(), |
| }); |
| } |
| |
| let uri = uri.into(); |
| let region = region.into(); |
| let signing_algorithm = signing_algorithm.into(); |
| let signer = DLFSignerFactory::create_signer(&signing_algorithm, ®ion); |
| |
| Ok(Self { |
| uri, |
| token: tokio::sync::Mutex::new(token), |
| token_loader, |
| signer, |
| }) |
| } |
| |
| /// Get or refresh the token. |
| /// |
| /// If token_loader is configured, this method will: |
| /// - Load a new token if current token is None |
| /// - Refresh the token if it's about to expire (within TOKEN_EXPIRATION_SAFE_TIME_MILLIS) |
| async fn get_or_refresh_token(&self) -> Result<DLFToken> { |
| let mut token_guard = self.token.lock().await; |
| |
| if let Some(loader) = &self.token_loader { |
| let need_reload = match &*token_guard { |
| None => true, |
| Some(token) => match token.expiration_at_millis { |
| Some(expiration_at_millis) => { |
| let now = chrono::Utc::now().timestamp_millis(); |
| expiration_at_millis - now < TOKEN_EXPIRATION_SAFE_TIME_MILLIS |
| } |
| None => false, |
| }, |
| }; |
| |
| if need_reload { |
| let new_token = loader.load_token().await?; |
| *token_guard = Some(new_token); |
| } |
| } |
| |
| token_guard.clone().ok_or_else(|| Error::DataInvalid { |
| message: "Either token or token_loader must be provided".to_string(), |
| source: None, |
| }) |
| } |
| |
| /// Extract host from URI. |
| fn extract_host(uri: &str) -> String { |
| let without_protocol = uri |
| .strip_prefix("https://") |
| .or_else(|| uri.strip_prefix("http://")) |
| .unwrap_or(uri); |
| |
| let path_index = without_protocol.find('/').unwrap_or(without_protocol.len()); |
| without_protocol[..path_index].to_string() |
| } |
| } |
| |
| #[async_trait] |
| impl AuthProvider for DLFAuthProvider { |
| async fn merge_auth_header( |
| &self, |
| mut base_header: HashMap<String, String>, |
| rest_auth_parameter: &RESTAuthParameter, |
| ) -> crate::Result<HashMap<String, String>> { |
| // Get token (will auto-refresh if needed via token_loader) |
| let token = self.get_or_refresh_token().await?; |
| |
| let now = Utc::now(); |
| let host = Self::extract_host(&self.uri); |
| |
| // Generate signature headers |
| let sign_headers = self.signer.sign_headers( |
| rest_auth_parameter.data.as_deref(), |
| &now, |
| token.security_token.as_deref(), |
| &host, |
| ); |
| |
| // Generate authorization header |
| let authorization = |
| self.signer |
| .authorization(rest_auth_parameter, &token, &host, &sign_headers); |
| |
| // Merge all headers |
| base_header.extend(sign_headers); |
| base_header.insert(AUTHORIZATION_HEADER_KEY.to_string(), authorization); |
| |
| Ok(base_header) |
| } |
| } |
| |
| // ============================================================================ |
| // DLF Token Loader Implementation |
| // ============================================================================ |
| |
| /// HTTP client for token loading with retry and timeout configuration. |
| struct TokenHTTPClient { |
| max_retries: u32, |
| client: Client, |
| } |
| |
| impl TokenHTTPClient { |
| /// Create a new HTTP client with default settings. |
| fn new() -> Self { |
| let connect_timeout = std::time::Duration::from_secs(180); // 3 minutes |
| let read_timeout = std::time::Duration::from_secs(180); // 3 minutes |
| |
| let client = Client::builder() |
| .timeout(read_timeout) |
| .connect_timeout(connect_timeout) |
| .build() |
| .expect("Failed to create HTTP client"); |
| |
| Self { |
| max_retries: 3, |
| client, |
| } |
| } |
| |
| /// Perform HTTP GET request with retry logic. |
| async fn get(&self, url: &str) -> Result<String> { |
| let mut last_error = String::new(); |
| for attempt in 0..self.max_retries { |
| match self.client.get(url).send().await { |
| Ok(response) if response.status().is_success() => { |
| return response.text().await.map_err(|e| Error::DataInvalid { |
| message: format!("Failed to read response: {e}"), |
| source: None, |
| }); |
| } |
| Ok(response) => { |
| last_error = format!("HTTP error: {}", response.status()); |
| } |
| Err(e) => { |
| last_error = format!("Request failed: {e}"); |
| } |
| } |
| |
| if attempt < self.max_retries - 1 { |
| // Exponential backoff |
| let delay = std::time::Duration::from_millis(100 * 2u64.pow(attempt)); |
| tokio::time::sleep(delay).await; |
| } |
| } |
| |
| Err(Error::DataInvalid { |
| message: last_error, |
| source: None, |
| }) |
| } |
| } |
| |
| #[cfg(test)] |
| mod tests { |
| use super::*; |
| |
| #[test] |
| fn test_extract_host() { |
| let uri = "http://dlf-abcdfgerrf.net/api/v1"; |
| let host = DLFAuthProvider::extract_host(uri); |
| assert_eq!(host, "dlf-abcdfgerrf.net"); |
| } |
| |
| #[test] |
| fn test_extract_host_no_path() { |
| let uri = "https://dlf.cn-abcdfgerrf.aliyuncs.com"; |
| let host = DLFAuthProvider::extract_host(uri); |
| assert_eq!(host, "dlf.cn-abcdfgerrf.aliyuncs.com"); |
| } |
| |
| #[test] |
| fn test_dlf_token_from_options() { |
| let mut options = Options::new(); |
| options.set(CatalogOptions::DLF_ACCESS_KEY_ID, "test_key_id"); |
| options.set(CatalogOptions::DLF_ACCESS_KEY_SECRET, "test_key_secret"); |
| options.set( |
| CatalogOptions::DLF_ACCESS_SECURITY_TOKEN, |
| "test_security_token", |
| ); |
| |
| let token = DLFToken::from_options(&options).unwrap(); |
| assert_eq!(token.access_key_id, "test_key_id"); |
| assert_eq!(token.access_key_secret, "test_key_secret"); |
| assert_eq!( |
| token.security_token, |
| Some("test_security_token".to_string()) |
| ); |
| } |
| |
| #[test] |
| fn test_dlf_token_missing_credentials() { |
| let options = Options::new(); |
| assert!(DLFToken::from_options(&options).is_none()); |
| } |
| |
| #[test] |
| fn test_parse_expiration() { |
| let expiration = "2024-12-31T23:59:59Z"; |
| let millis = DLFToken::parse_expiration_to_millis(expiration); |
| assert!(millis.is_some()); |
| } |
| } |