Skip to main content

mssql_auth/
cert_auth.rs

1//! Client certificate authentication provider.
2//!
3//! This module provides Azure AD authentication using a client certificate
4//! (X.509) instead of a client secret. This is more secure than using secrets
5//! because certificates can be stored in secure hardware (HSM) and have
6//! built-in expiration.
7//!
8//! ## How It Works
9//!
10//! Certificate authentication uses an Azure AD Service Principal with an
11//! X.509 certificate. The certificate's private key is used to sign a JWT
12//! assertion, which Azure AD validates using the certificate's public key
13//! registered with the application.
14//!
15//! **Important**: This is NOT TDS-level mTLS. SQL Server/Azure SQL do not
16//! support client certificates at the TDS protocol level. Instead, the
17//! certificate authenticates to Azure AD, which issues an access token
18//! used for SQL authentication.
19//!
20//! ## Prerequisites
21//!
22//! 1. Create an Azure AD App Registration
23//! 2. Generate or upload a certificate to the app registration
24//! 3. Export the certificate (PKCS#12 or PEM format)
25//! 4. Grant the service principal access to your Azure SQL database
26//!
27//! ## Example (PKCS#12)
28//!
29//! ```rust,ignore
30//! use mssql_auth::CertificateAuth;
31//! use std::fs;
32//!
33//! // Load PKCS#12 certificate from file
34//! let cert_bytes = fs::read("service-principal.pfx")?;
35//!
36//! let auth = CertificateAuth::new(
37//!     "your-tenant-id",
38//!     "your-client-id",
39//!     cert_bytes,
40//!     Some("certificate-password"),
41//! )?;
42//!
43//! // Get access token for Azure SQL
44//! let token = auth.get_token().await?;
45//! ```
46//!
47//! ## Example (PEM)
48//!
49//! PEM certificates are common in Linux/Kubernetes environments:
50//!
51//! ```rust,ignore
52//! use mssql_auth::CertificateAuth;
53//! use std::fs;
54//!
55//! // Load PEM certificate and private key
56//! let cert_pem = fs::read("cert.pem")?;
57//! let key_pem = fs::read("key.pem")?;
58//!
59//! let auth = CertificateAuth::from_pem(
60//!     "your-tenant-id",
61//!     "your-client-id",
62//!     &cert_pem,
63//!     &key_pem,
64//!     None, // optional password
65//! )?;
66//!
67//! let token = auth.get_token().await?;
68//! ```
69//!
70//! ## Security Considerations
71//!
72//! - Store certificates in Azure Key Vault or secure hardware when possible
73//! - Use certificates with appropriate key sizes (RSA 2048+ or ECDSA P-256+)
74//! - Set reasonable certificate expiration (1-2 years)
75//! - Rotate certificates before expiration
76//! - Never commit certificates to source control
77
78use std::sync::Arc;
79use std::time::Duration;
80
81use azure_core::credentials::TokenCredential;
82use azure_identity::ClientCertificateCredential;
83
84use crate::AzureAdAuth;
85use crate::error::AuthError;
86
87/// The Azure SQL Database scope for token requests.
88const AZURE_SQL_SCOPE: &str = "https://database.windows.net/.default";
89
90/// Client certificate authentication provider.
91///
92/// Uses an X.509 certificate to authenticate as an Azure AD Service Principal,
93/// then acquires an access token for Azure SQL Database.
94///
95/// # Security
96///
97/// Certificate authentication is more secure than client secrets because:
98/// - Certificates have built-in expiration
99/// - Private keys can be stored in secure hardware (HSM/TPM)
100/// - Certificates support hardware-based attestation
101/// - Certificate rotation doesn't require application restarts
102pub struct CertificateAuth {
103    credential: Arc<ClientCertificateCredential>,
104}
105
106impl CertificateAuth {
107    /// Create a new certificate authentication provider.
108    ///
109    /// # Arguments
110    ///
111    /// * `tenant_id` - The Azure AD tenant ID
112    /// * `client_id` - The application (client) ID of the service principal
113    /// * `certificate` - The PKCS#12 (.pfx) certificate bytes (base64-encoded or raw)
114    /// * `password` - Optional password for the certificate's private key
115    ///
116    /// # Errors
117    ///
118    /// Returns an error if the certificate cannot be parsed or the credential
119    /// cannot be created.
120    ///
121    /// # Example
122    ///
123    /// ```rust,ignore
124    /// use mssql_auth::CertificateAuth;
125    /// use std::fs;
126    ///
127    /// let cert = fs::read("app.pfx")?;
128    /// let auth = CertificateAuth::new(
129    ///     "tenant-id",
130    ///     "client-id",
131    ///     cert,
132    ///     Some("cert-password"),
133    /// )?;
134    /// ```
135    pub fn new(
136        tenant_id: impl AsRef<str>,
137        client_id: impl Into<String>,
138        certificate: impl AsRef<[u8]>,
139        password: Option<&str>,
140    ) -> Result<Self, AuthError> {
141        use azure_core::credentials::Secret;
142        use azure_identity::ClientCertificateCredentialOptions;
143        use base64::Engine;
144
145        // azure_identity expects RAW DER PKCS#12 bytes (it calls
146        // `Pkcs12::from_der`). Accept either raw DER or a base64-encoded
147        // wrapper and hand azure_identity the decoded DER.
148        let cert_bytes = certificate.as_ref();
149        let der: Vec<u8> = if is_base64(cert_bytes) {
150            base64::engine::general_purpose::STANDARD
151                .decode(cert_bytes)
152                .map_err(|e| {
153                    AuthError::Certificate(format!("base64-decoding the certificate failed: {e}"))
154                })?
155        } else {
156            cert_bytes.to_vec()
157        };
158
159        let cert_secret = azure_core::credentials::SecretBytes::new(der);
160
161        // Create options with password if provided
162        // Note: send_certificate_chain is now controlled by AZURE_CLIENT_SEND_CERTIFICATE_CHAIN env var
163        let options = if let Some(pwd) = password {
164            ClientCertificateCredentialOptions {
165                password: Some(Secret::new(pwd.to_string())),
166                ..Default::default()
167            }
168        } else {
169            ClientCertificateCredentialOptions::default()
170        };
171
172        let credential = ClientCertificateCredential::new(
173            tenant_id.as_ref().to_string(),
174            client_id.into(),
175            cert_secret,
176            Some(options),
177        )
178        .map_err(|e| {
179            AuthError::Certificate(format!("Failed to create certificate credential: {e}"))
180        })?;
181
182        Ok(Self { credential })
183    }
184
185    /// Create a new certificate authentication provider from PEM-encoded files.
186    ///
187    /// This is a convenience method for users who have PEM-formatted certificates
188    /// (common in Linux/Kubernetes environments) rather than PKCS#12 format.
189    ///
190    /// # Arguments
191    ///
192    /// * `tenant_id` - The Azure AD tenant ID
193    /// * `client_id` - The application (client) ID of the service principal
194    /// * `cert_pem` - The PEM-encoded certificate (typically from a `.pem` or `.crt` file)
195    /// * `key_pem` - The PEM-encoded private key (typically from a `.key` or `.pem` file)
196    /// * `password` - Optional password for the PKCS#12 bundle (used during conversion)
197    ///
198    /// # Errors
199    ///
200    /// Returns an error if:
201    /// - The certificate PEM cannot be parsed
202    /// - The private key PEM cannot be parsed
203    /// - The PEM-to-PKCS#12 conversion fails
204    /// - The credential cannot be created
205    ///
206    /// # Example
207    ///
208    /// ```rust,ignore
209    /// use mssql_auth::CertificateAuth;
210    /// use std::fs;
211    ///
212    /// let cert_pem = fs::read("cert.pem")?;
213    /// let key_pem = fs::read("key.pem")?;
214    ///
215    /// let auth = CertificateAuth::from_pem(
216    ///     "tenant-id",
217    ///     "client-id",
218    ///     &cert_pem,
219    ///     &key_pem,
220    ///     None, // or Some("pkcs12-password")
221    /// )?;
222    /// ```
223    pub fn from_pem(
224        tenant_id: impl AsRef<str>,
225        client_id: impl Into<String>,
226        cert_pem: impl AsRef<[u8]>,
227        key_pem: impl AsRef<[u8]>,
228        password: Option<&str>,
229    ) -> Result<Self, AuthError> {
230        use std::io::BufReader;
231
232        // Parse certificate from PEM
233        let cert_pem_bytes = cert_pem.as_ref();
234        let mut cert_reader = BufReader::new(cert_pem_bytes);
235        let certs: Vec<_> = rustls_pemfile::certs(&mut cert_reader)
236            .collect::<Result<Vec<_>, _>>()
237            .map_err(|e| AuthError::Certificate(format!("Failed to parse certificate PEM: {e}")))?;
238
239        let cert_der = certs
240            .first()
241            .ok_or_else(|| AuthError::Certificate("No certificate found in PEM data".into()))?;
242
243        // Parse private key from PEM
244        let key_pem_bytes = key_pem.as_ref();
245        let mut key_reader = BufReader::new(key_pem_bytes);
246        let key_der = rustls_pemfile::private_key(&mut key_reader)
247            .map_err(|e| AuthError::Certificate(format!("Failed to parse private key PEM: {e}")))?
248            .ok_or_else(|| AuthError::Certificate("No private key found in PEM data".into()))?;
249
250        // Convert to PKCS#12 format
251        let pkcs12_password = password.unwrap_or("");
252        let pfx = p12::PFX::new(
253            cert_der.as_ref(),
254            key_der.secret_der(),
255            None, // No CA certificate
256            pkcs12_password,
257            "cert",
258        )
259        .ok_or_else(|| AuthError::Certificate("Failed to create PKCS#12 from PEM data".into()))?;
260
261        let pkcs12_bytes = pfx.to_der();
262
263        // Use existing constructor with the converted PKCS#12
264        Self::new(tenant_id, client_id, pkcs12_bytes, password)
265    }
266
267    /// Get an access token for Azure SQL Database.
268    ///
269    /// # Errors
270    ///
271    /// Returns an error if token acquisition fails (e.g., certificate invalid,
272    /// network error, insufficient permissions).
273    pub async fn get_token(&self) -> Result<String, AuthError> {
274        let token = self
275            .credential
276            .get_token(&[AZURE_SQL_SCOPE], None)
277            .await
278            .map_err(|e| AuthError::Certificate(format!("Failed to acquire token: {e}")))?;
279        Ok(token.token.secret().to_string())
280    }
281
282    /// Get an access token with expiration information.
283    ///
284    /// # Errors
285    ///
286    /// Returns an error if token acquisition fails.
287    pub async fn get_token_with_expiry(&self) -> Result<(String, Option<Duration>), AuthError> {
288        let token = self
289            .credential
290            .get_token(&[AZURE_SQL_SCOPE], None)
291            .await
292            .map_err(|e| AuthError::Certificate(format!("Failed to acquire token: {e}")))?;
293
294        // Calculate time until expiration
295        let now = time::OffsetDateTime::now_utc();
296        let expires_in = if token.expires_on > now {
297            let diff = token.expires_on - now;
298            Some(Duration::from_secs(diff.whole_seconds().max(0) as u64))
299        } else {
300            None
301        };
302
303        Ok((token.token.secret().to_string(), expires_in))
304    }
305
306    /// Convert to an `AzureAdAuth` provider with an acquired token.
307    ///
308    /// This is useful when you need to use the token with APIs that
309    /// expect `AzureAdAuth`.
310    ///
311    /// # Errors
312    ///
313    /// Returns an error if token acquisition fails.
314    pub async fn to_azure_ad_auth(&self) -> Result<AzureAdAuth, AuthError> {
315        let (token, expires_in) = self.get_token_with_expiry().await?;
316        match expires_in {
317            Some(duration) => Ok(AzureAdAuth::with_token_expiring(token, duration)),
318            None => Ok(AzureAdAuth::with_token(token)),
319        }
320    }
321}
322
323/// Check if bytes look like base64-encoded data.
324fn is_base64(data: &[u8]) -> bool {
325    // Simple heuristic: base64 contains only alphanumeric, +, /, =
326    // and PKCS#12 raw data would have binary bytes
327    data.iter().all(|&b| {
328        b.is_ascii_alphanumeric() || b == b'+' || b == b'/' || b == b'=' || b == b'\n' || b == b'\r'
329    })
330}
331
332impl Clone for CertificateAuth {
333    fn clone(&self) -> Self {
334        Self {
335            credential: Arc::clone(&self.credential),
336        }
337    }
338}
339
340impl std::fmt::Debug for CertificateAuth {
341    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
342        f.debug_struct("CertificateAuth")
343            .field("credential", &"[REDACTED]")
344            .finish()
345    }
346}
347
348impl crate::provider::AsyncAuthProvider for CertificateAuth {
349    fn method(&self) -> crate::provider::AuthMethod {
350        crate::provider::AuthMethod::AzureAd
351    }
352
353    async fn authenticate_async(&self) -> Result<crate::provider::AuthData, AuthError> {
354        let token = self.get_token().await?;
355        Ok(crate::provider::AuthData::FedAuth { token, nonce: None })
356    }
357
358    fn needs_refresh(&self) -> bool {
359        // Certificate-based tokens are acquired fresh each time
360        false
361    }
362}
363
364#[cfg(test)]
365#[allow(clippy::unwrap_used, clippy::expect_used)]
366mod tests {
367    use super::*;
368
369    // Note: These tests require Azure credentials and a valid certificate.
370    // They are marked as ignored and can be run manually with:
371    // cargo test --features cert-auth -- --ignored
372
373    #[test]
374    fn test_is_base64() {
375        assert!(is_base64(b"SGVsbG8gV29ybGQ="));
376        assert!(is_base64(b"MIIC+jCCAeKgAwIBAgIJAL"));
377        assert!(!is_base64(&[0x00, 0x01, 0x02, 0x03])); // Binary data
378    }
379
380    #[tokio::test]
381    #[ignore = "Requires Azure Service Principal with certificate"]
382    async fn test_certificate_auth() {
383        let tenant_id = std::env::var("AZURE_TENANT_ID").expect("AZURE_TENANT_ID not set");
384        let client_id = std::env::var("AZURE_CLIENT_ID").expect("AZURE_CLIENT_ID not set");
385        let cert_path = std::env::var("AZURE_CLIENT_CERTIFICATE_PATH")
386            .expect("AZURE_CLIENT_CERTIFICATE_PATH not set");
387        let cert_password = std::env::var("AZURE_CLIENT_CERTIFICATE_PASSWORD").ok();
388
389        let cert_bytes = std::fs::read(&cert_path).expect("Failed to read certificate");
390        let auth = CertificateAuth::new(tenant_id, client_id, cert_bytes, cert_password.as_deref())
391            .expect("Failed to create CertificateAuth");
392
393        let token = auth.get_token().await.expect("Failed to get token");
394        assert!(!token.is_empty());
395    }
396
397    #[test]
398    fn test_from_pem_invalid_certificate() {
399        let invalid_cert = b"not a valid PEM certificate";
400        let valid_key_format = b"-----BEGIN PRIVATE KEY-----\nMIIE=\n-----END PRIVATE KEY-----";
401
402        let result = CertificateAuth::from_pem(
403            "tenant-id",
404            "client-id",
405            invalid_cert,
406            valid_key_format,
407            None,
408        );
409
410        assert!(result.is_err());
411        let err = result.unwrap_err();
412        assert!(
413            err.to_string().contains("No certificate found"),
414            "Expected 'No certificate found' error, got: {err}"
415        );
416    }
417
418    #[test]
419    fn test_from_pem_invalid_private_key() {
420        // Valid PEM structure but not actually a valid cert (will fail at PKCS#12 conversion)
421        let cert_pem =
422            b"-----BEGIN CERTIFICATE-----\nMIIBkTCB+wIJAKHBfpE=\n-----END CERTIFICATE-----";
423        let invalid_key = b"not a valid PEM private key";
424
425        let result =
426            CertificateAuth::from_pem("tenant-id", "client-id", cert_pem, invalid_key, None);
427
428        assert!(result.is_err());
429        let err = result.unwrap_err();
430        assert!(
431            err.to_string().contains("No private key found"),
432            "Expected 'No private key found' error, got: {err}"
433        );
434    }
435
436    #[test]
437    fn test_from_pem_empty_certificate() {
438        let empty_cert = b"";
439        let key_pem = b"-----BEGIN PRIVATE KEY-----\nMIIE=\n-----END PRIVATE KEY-----";
440
441        let result = CertificateAuth::from_pem("tenant-id", "client-id", empty_cert, key_pem, None);
442
443        assert!(result.is_err());
444    }
445
446    #[test]
447    fn test_from_pem_empty_private_key() {
448        let cert_pem =
449            b"-----BEGIN CERTIFICATE-----\nMIIBkTCB+wIJAKHBfpE=\n-----END CERTIFICATE-----";
450        let empty_key = b"";
451
452        let result = CertificateAuth::from_pem("tenant-id", "client-id", cert_pem, empty_key, None);
453
454        assert!(result.is_err());
455    }
456
457    #[tokio::test]
458    #[ignore = "Requires Azure Service Principal with PEM certificate"]
459    async fn test_certificate_auth_from_pem() {
460        let tenant_id = std::env::var("AZURE_TENANT_ID").expect("AZURE_TENANT_ID not set");
461        let client_id = std::env::var("AZURE_CLIENT_ID").expect("AZURE_CLIENT_ID not set");
462        let cert_path = std::env::var("AZURE_CLIENT_CERTIFICATE_PEM")
463            .expect("AZURE_CLIENT_CERTIFICATE_PEM not set");
464        let key_path = std::env::var("AZURE_CLIENT_PRIVATE_KEY_PEM")
465            .expect("AZURE_CLIENT_PRIVATE_KEY_PEM not set");
466
467        let cert_pem = std::fs::read(&cert_path).expect("Failed to read certificate PEM");
468        let key_pem = std::fs::read(&key_path).expect("Failed to read private key PEM");
469
470        let auth = CertificateAuth::from_pem(tenant_id, client_id, &cert_pem, &key_pem, None)
471            .expect("Failed to create CertificateAuth from PEM");
472
473        let token = auth.get_token().await.expect("Failed to get token");
474        assert!(!token.is_empty());
475    }
476}