diff --git a/sdk/core/azure_core/src/cloud.rs b/sdk/core/azure_core/src/cloud.rs new file mode 100644 index 0000000000..60cd8af5b2 --- /dev/null +++ b/sdk/core/azure_core/src/cloud.rs @@ -0,0 +1,193 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +//! Cloud configuration for Azure services. +//! +//! This module provides cloud configurations for different Azure environments, +//! allowing services to operate across Azure Public Cloud, Azure China Cloud, +//! Azure Germany Cloud, and Azure US Government Cloud. + +use crate::http::Url; +use std::collections::HashMap; + +/// Configuration for a specific Azure cloud environment. +/// +/// This struct contains the endpoints and settings needed to connect to +/// a specific Azure cloud environment. It includes authority hosts for +/// authentication, resource manager endpoints, and service-specific +/// audience URIs for token requests. +#[derive(Debug, Clone)] +pub struct CloudConfiguration { + /// The authority host URL for authentication requests. + pub authority_host: Url, + + /// The resource manager endpoint for management operations. + pub resource_manager_endpoint: Url, + + /// Default audience for Azure Resource Manager. + pub resource_manager_audience: String, + + /// Map of service names to their audience URIs. + pub service_audiences: HashMap, +} + +impl CloudConfiguration { + /// Creates a new cloud configuration. + pub fn new( + authority_host: Url, + resource_manager_endpoint: Url, + resource_manager_audience: String, + ) -> Self { + Self { + authority_host, + resource_manager_endpoint, + resource_manager_audience, + service_audiences: HashMap::new(), + } + } + + /// Adds a service audience to the cloud configuration. + pub fn with_service_audience(mut self, service: impl Into, audience: impl Into) -> Self { + self.service_audiences.insert(service.into(), audience.into()); + self + } + + /// Gets the audience for a specific service. + pub fn service_audience(&self, service: &str) -> Option<&str> { + self.service_audiences.get(service).map(|s| s.as_str()) + } + + /// Gets the audience for Azure Resource Manager. + pub fn resource_manager_audience(&self) -> &str { + &self.resource_manager_audience + } + + /// Derives a scope from an audience URI. + /// + /// Azure OAuth 2.0 scopes are typically the audience URI with "/.default" appended. + pub fn audience_to_scope(audience: &str) -> String { + if audience.ends_with("/.default") { + audience.to_string() + } else { + format!("{}/.default", audience.trim_end_matches('/')) + } + } +} + +/// Well-known cloud configurations for Azure environments. +pub mod configurations { + use super::*; + use std::sync::OnceLock; + + /// Azure Public Cloud configuration. + pub fn azure_public_cloud() -> &'static CloudConfiguration { + static CONFIG: OnceLock = OnceLock::new(); + CONFIG.get_or_init(|| { + CloudConfiguration::new( + Url::parse("https://login.microsoftonline.com").unwrap(), + Url::parse("https://management.azure.com").unwrap(), + "https://management.azure.com".to_string(), + ) + .with_service_audience("storage", "https://storage.azure.com") + .with_service_audience("keyvault", "https://vault.azure.net") + }) + } + + /// Azure China Cloud configuration. + pub fn azure_china_cloud() -> &'static CloudConfiguration { + static CONFIG: OnceLock = OnceLock::new(); + CONFIG.get_or_init(|| { + CloudConfiguration::new( + Url::parse("https://login.chinacloudapi.cn").unwrap(), + Url::parse("https://management.chinacloudapi.cn").unwrap(), + "https://management.chinacloudapi.cn".to_string(), + ) + .with_service_audience("storage", "https://storage.azure.com") + .with_service_audience("keyvault", "https://vault.azure.cn") + }) + } + + /// Azure Germany Cloud configuration. + pub fn azure_germany_cloud() -> &'static CloudConfiguration { + static CONFIG: OnceLock = OnceLock::new(); + CONFIG.get_or_init(|| { + CloudConfiguration::new( + Url::parse("https://login.microsoftonline.de").unwrap(), + Url::parse("https://management.microsoftazure.de").unwrap(), + "https://management.microsoftazure.de".to_string(), + ) + .with_service_audience("storage", "https://storage.azure.com") + .with_service_audience("keyvault", "https://vault.microsoftazure.de") + }) + } + + /// Azure US Government Cloud configuration. + pub fn azure_us_government_cloud() -> &'static CloudConfiguration { + static CONFIG: OnceLock = OnceLock::new(); + CONFIG.get_or_init(|| { + CloudConfiguration::new( + Url::parse("https://login.microsoftonline.us").unwrap(), + Url::parse("https://management.usgovcloudapi.net").unwrap(), + "https://management.usgovcloudapi.net".to_string(), + ) + .with_service_audience("storage", "https://storage.azure.com") + .with_service_audience("keyvault", "https://vault.usgovcloudapi.net") + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_audience_to_scope() { + assert_eq!( + CloudConfiguration::audience_to_scope("https://management.azure.com"), + "https://management.azure.com/.default" + ); + assert_eq!( + CloudConfiguration::audience_to_scope("https://management.azure.com/"), + "https://management.azure.com/.default" + ); + assert_eq!( + CloudConfiguration::audience_to_scope("https://management.azure.com/.default"), + "https://management.azure.com/.default" + ); + } + + #[test] + fn test_cloud_configurations() { + let public = configurations::azure_public_cloud(); + assert_eq!(public.authority_host.as_str(), "https://login.microsoftonline.com/"); + assert_eq!(public.resource_manager_endpoint.as_str(), "https://management.azure.com/"); + assert_eq!(public.service_audience("storage"), Some("https://storage.azure.com")); + assert_eq!(public.service_audience("tables"), None); + + let china = configurations::azure_china_cloud(); + assert_eq!(china.authority_host.as_str(), "https://login.chinacloudapi.cn/"); + assert_eq!(china.resource_manager_endpoint.as_str(), "https://management.chinacloudapi.cn/"); + + let germany = configurations::azure_germany_cloud(); + assert_eq!(germany.authority_host.as_str(), "https://login.microsoftonline.de/"); + assert_eq!(germany.resource_manager_endpoint.as_str(), "https://management.microsoftazure.de/"); + + let us_gov = configurations::azure_us_government_cloud(); + assert_eq!(us_gov.authority_host.as_str(), "https://login.microsoftonline.us/"); + assert_eq!(us_gov.resource_manager_endpoint.as_str(), "https://management.usgovcloudapi.net/"); + } + + #[test] + fn test_service_audience() { + let mut config = CloudConfiguration::new( + Url::parse("https://login.microsoftonline.com").unwrap(), + Url::parse("https://management.azure.com").unwrap(), + "https://management.azure.com".to_string(), + ); + + assert_eq!(config.service_audience("storage"), None); + + config = config.with_service_audience("storage", "https://storage.azure.com"); + assert_eq!(config.service_audience("storage"), Some("https://storage.azure.com")); + } +} \ No newline at end of file diff --git a/sdk/core/azure_core/src/constants.rs b/sdk/core/azure_core/src/constants.rs index 586a24c0d0..7679c27e18 100644 --- a/sdk/core/azure_core/src/constants.rs +++ b/sdk/core/azure_core/src/constants.rs @@ -2,6 +2,12 @@ // Licensed under the MIT License. /// Endpoints for Azure Resource Manager in different Azure clouds +/// +/// # Deprecated +/// These constants are deprecated. Use [`crate::cloud::configurations`] instead +/// for a more comprehensive cloud configuration system that includes authority hosts, +/// resource manager endpoints, and service audiences. +#[deprecated(since = "0.28.0", note = "Use `azure_core::cloud::configurations` instead")] pub mod resource_manager_endpoint { static_url!( /// Azure Resource Manager China cloud endpoint @@ -29,6 +35,12 @@ pub mod resource_manager_endpoint { } /// A list of known Azure authority hosts +/// +/// # Deprecated +/// These constants are deprecated. Use [`crate::cloud::configurations`] instead +/// for a more comprehensive cloud configuration system that includes authority hosts, +/// resource manager endpoints, and service audiences. +#[deprecated(since = "0.28.0", note = "Use `azure_core::cloud::configurations` instead")] pub mod authority_hosts { static_url!( /// China-based Azure Authority Host diff --git a/sdk/core/azure_core/src/http/options/mod.rs b/sdk/core/azure_core/src/http/options/mod.rs index 9769b14f3f..a755e2da55 100644 --- a/sdk/core/azure_core/src/http/options/mod.rs +++ b/sdk/core/azure_core/src/http/options/mod.rs @@ -5,6 +5,7 @@ mod instrumentation; mod user_agent; pub use instrumentation::*; +use crate::cloud::CloudConfiguration; use std::sync::Arc; use typespec_client_core::http::policies::Policy; pub use typespec_client_core::http::{ @@ -13,6 +14,30 @@ pub use typespec_client_core::http::{ pub use user_agent::*; /// Client options allow customization of general client policies, retry options, and more. +/// +/// # Examples +/// +/// ## Basic usage with default (Public Cloud) configuration: +/// ``` +/// use azure_core::http::ClientOptions; +/// +/// let options = ClientOptions::default(); +/// ``` +/// +/// ## Using a specific cloud configuration: +/// ``` +/// use azure_core::http::ClientOptions; +/// use azure_core::cloud::configurations; +/// +/// // Configure for Azure China Cloud +/// let options = ClientOptions::default() +/// .with_cloud(configurations::azure_china_cloud().clone()) +/// .with_audience("https://storage.azure.com"); +/// +/// // Get the OAuth scope for authentication +/// let scope = options.get_auth_scope(Some("storage")); +/// assert_eq!(scope, Some("https://storage.azure.com/.default".to_string())); +/// ``` #[derive(Clone, Debug, Default)] pub struct ClientOptions { /// Policies called per call. @@ -35,14 +60,64 @@ pub struct ClientOptions { /// If not specified, defaults to no instrumentation. /// pub instrumentation: Option, + + /// Cloud configuration for determining endpoints and audiences. + /// + /// If not specified, defaults to Azure Public Cloud. + pub cloud: Option, + + /// Service audience for token requests. + /// + /// This is typically the base URI of the service being accessed. + /// If not specified, the audience will be derived from the cloud configuration + /// for known services, or default to the resource manager audience. + pub audience: Option, } pub(crate) struct CoreClientOptions { pub(crate) user_agent: UserAgentOptions, pub(crate) instrumentation: InstrumentationOptions, + pub(crate) cloud: Option, + pub(crate) audience: Option, } impl ClientOptions { + /// Set the cloud configuration. + /// + /// This determines the endpoints and audiences used for authentication + /// and service requests. + pub fn with_cloud(mut self, cloud: CloudConfiguration) -> Self { + self.cloud = Some(cloud); + self + } + + /// Set the service audience for token requests. + /// + /// The audience should be the base URI of the service being accessed. + /// For example, for Azure Storage, use "https://storage.azure.com". + pub fn with_audience(mut self, audience: impl Into) -> Self { + self.audience = Some(audience.into()); + self + } + + /// Get the scope for authentication based on audience and cloud configuration. + /// + /// This is a convenience method that derives the OAuth scope from the audience. + /// If no audience is explicitly set, it will try to derive one from the cloud + /// configuration for known services. + pub fn get_auth_scope(&self, service_name: Option<&str>) -> Option { + let cloud = self.cloud.as_ref().unwrap_or_else(|| crate::cloud::configurations::azure_public_cloud()); + + if let Some(audience) = &self.audience { + Some(CloudConfiguration::audience_to_scope(audience)) + } else if let Some(service) = service_name { + cloud.service_audience(service) + .map(CloudConfiguration::audience_to_scope) + } else { + Some(CloudConfiguration::audience_to_scope(cloud.resource_manager_audience())) + } + } + /// Efficiently deconstructs into owned [`typespec_client_core::http::ClientOptions`] as well as unwrapped or default Azure-specific options. /// /// If instead we implemented [`Into`], we'd have to clone Azure-specific options instead of moving memory of [`Some`] values. @@ -60,8 +135,87 @@ impl ClientOptions { CoreClientOptions { user_agent: self.user_agent.unwrap_or_default(), instrumentation: self.instrumentation.unwrap_or_default(), + cloud: self.cloud, + audience: self.audience, }, options, ) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::cloud::configurations; + + #[test] + fn test_get_auth_scope_with_explicit_audience() { + let options = ClientOptions::default() + .with_audience("https://storage.azure.com"); + + let scope = options.get_auth_scope(None); + assert_eq!(scope, Some("https://storage.azure.com/.default".to_string())); + } + + #[test] + fn test_get_auth_scope_with_service_name() { + let options = ClientOptions::default() + .with_cloud(configurations::azure_public_cloud().clone()); + + let scope = options.get_auth_scope(Some("storage")); + assert_eq!(scope, Some("https://storage.azure.com/.default".to_string())); + + let scope = options.get_auth_scope(Some("keyvault")); + assert_eq!(scope, Some("https://vault.azure.net/.default".to_string())); + } + + #[test] + fn test_get_auth_scope_default_resource_manager() { + let options = ClientOptions::default(); + + let scope = options.get_auth_scope(None); + assert_eq!(scope, Some("https://management.azure.com/.default".to_string())); + } + + #[test] + fn test_get_auth_scope_china_cloud() { + let options = ClientOptions::default() + .with_cloud(configurations::azure_china_cloud().clone()); + + let scope = options.get_auth_scope(Some("keyvault")); + assert_eq!(scope, Some("https://vault.azure.cn/.default".to_string())); + } + + #[test] + fn test_explicit_audience_overrides_service_name() { + let options = ClientOptions::default() + .with_cloud(configurations::azure_public_cloud().clone()) + .with_audience("https://custom.service.com"); + + let scope = options.get_auth_scope(Some("storage")); + assert_eq!(scope, Some("https://custom.service.com/.default".to_string())); + } + + #[test] + fn test_scope_derivation_for_different_services() { + // Test that the scope derivation works correctly for different Azure services + let options = ClientOptions::default() + .with_cloud(configurations::azure_public_cloud().clone()); + + // Test KeyVault service + let keyvault_scope = options.get_auth_scope(Some("keyvault")); + assert_eq!(keyvault_scope, Some("https://vault.azure.net/.default".to_string())); + + // Test Storage service + let storage_scope = options.get_auth_scope(Some("storage")); + assert_eq!(storage_scope, Some("https://storage.azure.com/.default".to_string())); + + // Test unknown service (falls back to None for unknown services) + let unknown_scope = options.get_auth_scope(Some("unknown")); + assert_eq!(unknown_scope, None); + + // Test no service specified (uses resource manager) + let default_scope = options.get_auth_scope(None); + assert_eq!(default_scope, Some("https://management.azure.com/.default".to_string())); + } +} diff --git a/sdk/core/azure_core/src/lib.rs b/sdk/core/azure_core/src/lib.rs index 0a51aca5c0..a2adefb21b 100644 --- a/sdk/core/azure_core/src/lib.rs +++ b/sdk/core/azure_core/src/lib.rs @@ -9,6 +9,7 @@ #[macro_use] mod macros; +pub mod cloud; mod constants; pub mod credentials; pub mod hmac; diff --git a/sdk/identity/azure_identity/src/options.rs b/sdk/identity/azure_identity/src/options.rs index 17f7220130..b6d6e2f5e9 100644 --- a/sdk/identity/azure_identity/src/options.rs +++ b/sdk/identity/azure_identity/src/options.rs @@ -5,6 +5,7 @@ use crate::env::Env; #[cfg(not(target_arch = "wasm32"))] use crate::process::{new_executor, Executor}; use azure_core::{ + cloud::CloudConfiguration, error::{ErrorKind, Result, ResultExt}, http::{new_http_client, HttpClient, Url}, }; @@ -20,6 +21,7 @@ pub struct TokenCredentialOptions { pub(crate) env: Env, pub(crate) http_client: Arc, pub(crate) authority_host: String, + pub(crate) cloud: Option, #[cfg(not(target_arch = "wasm32"))] pub(crate) executor: Arc, } @@ -28,6 +30,7 @@ pub struct TokenCredentialOptions { /// /// The authority host is taken from the `AZURE_AUTHORITY_HOST` environment variable if set and a valid URL. /// If not, the default authority host is `https://login.microsoftonline.com` for the Azure public cloud. +/// The cloud configuration can be set explicitly using `with_cloud()` for non-public clouds. impl Default for TokenCredentialOptions { fn default() -> Self { let env = Env::default(); @@ -38,6 +41,7 @@ impl Default for TokenCredentialOptions { env: Env::default(), http_client: new_http_client(), authority_host, + cloud: None, #[cfg(not(target_arch = "wasm32"))] executor: new_executor(), } @@ -45,6 +49,17 @@ impl Default for TokenCredentialOptions { } impl TokenCredentialOptions { + /// Set the cloud configuration for authentication requests. + /// + /// This allows credentials to work with different Azure clouds + /// (Public, China, Germany, US Government) by setting the appropriate + /// authority host and other cloud-specific settings. + pub fn with_cloud(mut self, cloud: CloudConfiguration) -> Self { + self.authority_host = cloud.authority_host.to_string(); + self.cloud = Some(cloud); + self + } + /// Set the authority host for authentication requests. pub fn set_authority_host(&mut self, authority_host: String) { self.authority_host = authority_host; @@ -54,9 +69,15 @@ impl TokenCredentialOptions { /// /// The default is `https://login.microsoftonline.com`. pub fn authority_host(&self) -> Result { - Url::parse(&self.authority_host).with_context(ErrorKind::DataConversion, || { - format!("invalid authority host URL {}", &self.authority_host) - }) + // If cloud config is set, use it; otherwise use the explicit authority_host + let host = if let Some(config) = &self.cloud { + config.authority_host.clone() + } else { + Url::parse(&self.authority_host).with_context(ErrorKind::DataConversion, || { + format!("invalid authority host URL {}", &self.authority_host) + })? + }; + Ok(host) } /// The [`HttpClient`] to make requests. @@ -83,3 +104,44 @@ impl From> for TokenCredentialOptions { } } } + +#[cfg(test)] +mod tests { + use super::*; + use azure_core::cloud::configurations; + + #[test] + fn test_default_options() { + let options = TokenCredentialOptions::default(); + assert_eq!(options.authority_host, AZURE_PUBLIC_CLOUD); + assert!(options.cloud.is_none()); + } + + #[test] + fn test_set_cloud() { + let options = TokenCredentialOptions::default() + .with_cloud(configurations::azure_china_cloud().clone()); + + assert_eq!( + options.cloud.as_ref().unwrap().authority_host.as_str(), + "https://login.chinacloudapi.cn/" + ); + assert_eq!(options.authority_host, "https://login.chinacloudapi.cn/"); + } + + #[test] + fn test_authority_host_with_cloud() { + let options = TokenCredentialOptions::default() + .with_cloud(configurations::azure_us_government_cloud().clone()); + + let authority_host = options.authority_host().unwrap(); + assert_eq!(authority_host.as_str(), "https://login.microsoftonline.us/"); + } + + #[test] + fn test_authority_host_without_cloud() { + let options = TokenCredentialOptions::default(); + let authority_host = options.authority_host().unwrap(); + assert_eq!(authority_host.as_str(), "https://login.microsoftonline.com/"); + } +}