Skip to main content

sqlserver_mcp_catalog/auth/strategies/
azure_ad.rs

1// SQL Server 2025 - master/msdb/sandbox combined catalog MCP server.
2//
3// `azureADAuth` (Azure Active Directory / Microsoft Entra ID — see
4// docs/sqlserver-eda-openapi-pipeline/README.md's `securitySchemes`
5// documentation; only SQL Server 2022/2025 accept it). Adapted from
6// mcpify's originally generated `strategies::oauth2` (the OpenAPI `oauth2`
7// scheme this maps from): the token exchange itself is the same standard
8// OAuth2 mechanics, just the client-credentials grant (service-to-service —
9// an MCP server has no interactive browser/redirect available at tool-call
10// time, unlike `oauth2.rs`'s authorization-code flow) against Azure AD's
11// token endpoint, requesting the `https://database.windows.net/.default`
12// scope the spec's `azureADAuth.flows` declares.
13//
14// `services::api_client` passes the resulting `access_token` straight to
15// `tiberius::AuthMethod::AADToken(token)` — tiberius only *consumes* a
16// pre-obtained AAD token, it doesn't perform the OAuth exchange itself,
17// which is exactly what this strategy is for.
18//
19// Token lifetime is typically ~1 hour; this strategy refreshes via a fresh
20// client-credentials exchange (no separate refresh token exists for this
21// grant type) once `validate_credentials` reports the cached token
22// expired, the same expiry-driven reuse `AuthManager::credentials` already
23// does for every strategy — a long-running server process re-authenticates
24// automatically on the next tool call after expiry, not via a background
25// timer.
26
27use 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(&params)
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    /// Client-credentials grants have no separate refresh token — a
122    /// "refresh" is just re-running the same exchange with the credentials
123    /// already on hand.
124    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}