sqlserver_mcp_catalog/auth/strategies/
azure_ad.rs1use std::time::{SystemTime, UNIX_EPOCH};
28
29use async_trait::async_trait;
30use serde::Deserialize;
31
32use super::super::auth_strategy::{AuthConfig, AuthStrategy, Credentials};
33use super::super::errors::AuthError;
34
35const DEFAULT_SCOPE: &str = "https://database.windows.net/.default";
36
37#[derive(Debug, Deserialize)]
38struct TokenResponse {
39 access_token: String,
40 expires_in: Option<u64>,
41}
42
43#[derive(Debug, Default)]
44pub struct AzureAdAuthStrategy;
45
46impl AzureAdAuthStrategy {
47 fn token_url(config: &AuthConfig) -> anyhow::Result<String> {
48 if let Some(url) = config.get("token_url") {
49 return Ok(url.clone());
50 }
51 let tenant_id = config.get("tenant_id").ok_or_else(|| {
52 AuthError::MissingCredentials(
53 "tenant_id (or token_url), client_id, client_secret".to_string(),
54 )
55 })?;
56 Ok(format!(
57 "https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
58 ))
59 }
60
61 fn from_token_response(
62 data: TokenResponse,
63 config: &AuthConfig,
64 token_url: &str,
65 ) -> Credentials {
66 let mut credentials = Credentials::new();
67 credentials.insert("access_token".to_string(), data.access_token);
68 if let Some(expires_in) = data.expires_in {
69 let now_ms = SystemTime::now()
70 .duration_since(UNIX_EPOCH)
71 .unwrap_or_default()
72 .as_millis() as u64;
73 credentials.insert(
74 "expires_at".to_string(),
75 (now_ms + expires_in * 1000).to_string(),
76 );
77 }
78 for key in ["client_id", "client_secret", "tenant_id", "scope"] {
79 if let Some(value) = config.get(key) {
80 credentials.insert(key.to_string(), value.clone());
81 }
82 }
83 credentials.insert("token_url".to_string(), token_url.to_string());
84 credentials
85 }
86}
87
88#[async_trait]
89impl AuthStrategy for AzureAdAuthStrategy {
90 async fn authenticate(&self, config: &AuthConfig) -> anyhow::Result<Credentials> {
91 let client_id = config.get("client_id").ok_or_else(|| {
92 AuthError::MissingCredentials("client_id, client_secret, tenant_id".to_string())
93 })?;
94 let client_secret = config.get("client_secret").ok_or_else(|| {
95 AuthError::MissingCredentials("client_id, client_secret, tenant_id".to_string())
96 })?;
97 let token_url = Self::token_url(config)?;
98 let scope = config
99 .get("scope")
100 .map(String::as_str)
101 .unwrap_or(DEFAULT_SCOPE);
102
103 let params = [
104 ("grant_type", "client_credentials"),
105 ("client_id", client_id.as_str()),
106 ("client_secret", client_secret.as_str()),
107 ("scope", scope),
108 ];
109 let response = reqwest::Client::new()
110 .post(&token_url)
111 .form(¶ms)
112 .send()
113 .await?
114 .error_for_status()?
115 .json::<TokenResponse>()
116 .await?;
117
118 Ok(Self::from_token_response(response, config, &token_url))
119 }
120
121 async fn refresh_token(&self, credentials: &Credentials) -> anyhow::Result<Credentials> {
125 self.authenticate(credentials).await
126 }
127
128 fn validate_credentials(&self, credentials: &Credentials) -> bool {
129 if !credentials.contains_key("access_token") {
130 return false;
131 }
132 let Some(expires_at) = credentials.get("expires_at") else {
133 return true;
134 };
135 let Ok(expires_at) = expires_at.parse::<u64>() else {
136 return false;
137 };
138 let now_ms = SystemTime::now()
139 .duration_since(UNIX_EPOCH)
140 .unwrap_or_default()
141 .as_millis() as u64;
142 now_ms < expires_at
143 }
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149
150 #[test]
151 fn token_url_defaults_to_the_tenant_specific_v2_endpoint() {
152 let config = AuthConfig::from([("tenant_id".to_string(), "abc-123".to_string())]);
153 assert_eq!(
154 AzureAdAuthStrategy::token_url(&config).unwrap(),
155 "https://login.microsoftonline.com/abc-123/oauth2/v2.0/token"
156 );
157 }
158
159 #[test]
160 fn token_url_prefers_an_explicit_override() {
161 let config = AuthConfig::from([(
162 "token_url".to_string(),
163 "https://example.com/token".to_string(),
164 )]);
165 assert_eq!(
166 AzureAdAuthStrategy::token_url(&config).unwrap(),
167 "https://example.com/token"
168 );
169 }
170
171 #[test]
172 fn token_url_errors_without_either_tenant_id_or_token_url() {
173 assert!(AzureAdAuthStrategy::token_url(&AuthConfig::new()).is_err());
174 }
175
176 #[test]
177 fn validates_a_token_with_no_expiry_as_always_valid() {
178 let strategy = AzureAdAuthStrategy;
179 let credentials = Credentials::from([("access_token".to_string(), "abc".to_string())]);
180 assert!(strategy.validate_credentials(&credentials));
181 }
182
183 #[test]
184 fn rejects_an_expired_token() {
185 let strategy = AzureAdAuthStrategy;
186 let credentials = Credentials::from([
187 ("access_token".to_string(), "abc".to_string()),
188 ("expires_at".to_string(), "1".to_string()),
189 ]);
190 assert!(!strategy.validate_credentials(&credentials));
191 }
192
193 #[test]
194 fn rejects_credentials_with_no_access_token() {
195 let strategy = AzureAdAuthStrategy;
196 assert!(!strategy.validate_credentials(&Credentials::new()));
197 }
198}