Skip to main content

rigg_client/
auth.rs

1//! Azure authentication providers
2
3use std::process::Command;
4use thiserror::Error;
5
6/// Authentication errors
7#[derive(Debug, Error)]
8pub enum AuthError {
9    #[error(
10        "Azure CLI not found. Please install it: https://docs.microsoft.com/cli/azure/install-azure-cli"
11    )]
12    AzCliNotFound,
13    #[error("Not logged in to Azure CLI. Run: az login")]
14    NotLoggedIn,
15    #[error("Failed to get access token: {0}")]
16    TokenError(String),
17    #[error("Missing environment variable: {0}")]
18    MissingEnvVar(String),
19    #[error("Authentication failed: {0}")]
20    AuthFailed(String),
21}
22
23/// Build an actionable error for a failure to parse `az account show` output.
24///
25/// A bare serde error (e.g. `expected value at line 1 column 1`) gives the
26/// user no idea what to do; this is almost always a transient Azure CLI
27/// hiccup (extension update noise, empty stdout, etc.), so point them at the
28/// obvious next steps.
29fn account_parse_error(e: serde_json::Error) -> AuthError {
30    AuthError::TokenError(format!(
31        "could not parse `az account show` output ({e}); this is usually a \
32         transient Azure CLI issue — try again, and if it persists run `az login`"
33    ))
34}
35
36/// Turn `az`'s stderr into a `TokenError` detail, substituting an actionable
37/// fallback message when stderr is empty or whitespace-only (which otherwise
38/// surfaces to the user as a blank cause).
39fn token_error_detail(stderr: &str, status: std::process::ExitStatus) -> String {
40    if stderr.trim().is_empty() {
41        format!(
42            "az returned no error detail (exit {status}); usually transient — try again, or run `az login`"
43        )
44    } else {
45        stderr.trim().to_string()
46    }
47}
48
49/// Authentication provider trait
50pub trait AuthProvider: Send + Sync {
51    /// Get an access token for Azure Search
52    fn get_token(&self) -> Result<String, AuthError>;
53
54    /// Get the authentication method name
55    fn method_name(&self) -> &'static str;
56}
57
58/// Azure CLI authentication provider
59pub struct AzCliAuth {
60    resource_scope: &'static str,
61}
62
63impl AzCliAuth {
64    /// Create an auth provider for Azure Search
65    pub fn for_search() -> Self {
66        Self {
67            resource_scope: "https://search.azure.com",
68        }
69    }
70
71    /// Create an auth provider for Microsoft Foundry
72    pub fn for_foundry() -> Self {
73        Self {
74            resource_scope: "https://ai.azure.com",
75        }
76    }
77
78    /// Create an auth provider for Azure Cognitive Services (OpenAI)
79    pub fn for_cognitive_services() -> Self {
80        Self {
81            resource_scope: "https://cognitiveservices.azure.com",
82        }
83    }
84
85    /// Create an auth provider for Azure Cosmos DB
86    pub fn for_cosmos() -> Self {
87        Self {
88            resource_scope: "https://cosmos.azure.com",
89        }
90    }
91
92    /// Create a new auth provider (defaults to Search scope for backward compatibility)
93    pub fn new() -> Self {
94        Self::for_search()
95    }
96
97    /// Check if Azure CLI is available and logged in
98    pub fn check_status() -> Result<AuthStatus, AuthError> {
99        // Check if az CLI is installed
100        let version_output = Command::new("az").arg("--version").output();
101
102        if version_output.is_err() {
103            return Err(AuthError::AzCliNotFound);
104        }
105
106        // Check if logged in
107        let account_output = Command::new("az")
108            .args(["account", "show", "--output", "json"])
109            .output()
110            .map_err(|e| AuthError::TokenError(e.to_string()))?;
111
112        if !account_output.status.success() {
113            return Err(AuthError::NotLoggedIn);
114        }
115
116        // Parse account info
117        let account_json: serde_json::Value =
118            serde_json::from_slice(&account_output.stdout).map_err(account_parse_error)?;
119
120        Ok(AuthStatus {
121            logged_in: true,
122            user: account_json
123                .get("user")
124                .and_then(|u| u.get("name"))
125                .and_then(|n| n.as_str())
126                .map(String::from),
127            subscription: account_json
128                .get("name")
129                .and_then(|n| n.as_str())
130                .map(String::from),
131            subscription_id: account_json
132                .get("id")
133                .and_then(|i| i.as_str())
134                .map(String::from),
135        })
136    }
137
138    /// Get an access token for Azure Resource Manager (management.azure.com)
139    pub fn get_arm_token() -> Result<String, AuthError> {
140        let output = Command::new("az")
141            .args([
142                "account",
143                "get-access-token",
144                "--resource",
145                "https://management.azure.com",
146                "--query",
147                "accessToken",
148                "--output",
149                "tsv",
150            ])
151            .output()
152            .map_err(|e| AuthError::TokenError(e.to_string()))?;
153
154        if !output.status.success() {
155            let stderr = String::from_utf8_lossy(&output.stderr);
156            if stderr.contains("not logged in") || stderr.contains("AADSTS") {
157                return Err(AuthError::NotLoggedIn);
158            }
159            return Err(AuthError::TokenError(token_error_detail(
160                &stderr,
161                output.status,
162            )));
163        }
164
165        let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
166        if token.is_empty() {
167            return Err(AuthError::TokenError(
168                "Empty ARM token received".to_string(),
169            ));
170        }
171
172        Ok(token)
173    }
174}
175
176impl Default for AzCliAuth {
177    fn default() -> Self {
178        Self::new()
179    }
180}
181
182impl AuthProvider for AzCliAuth {
183    fn get_token(&self) -> Result<String, AuthError> {
184        let output = Command::new("az")
185            .args([
186                "account",
187                "get-access-token",
188                "--resource",
189                self.resource_scope,
190                "--query",
191                "accessToken",
192                "--output",
193                "tsv",
194            ])
195            .output()
196            .map_err(|e| AuthError::TokenError(e.to_string()))?;
197
198        if !output.status.success() {
199            let stderr = String::from_utf8_lossy(&output.stderr);
200            if stderr.contains("not logged in") {
201                return Err(AuthError::NotLoggedIn);
202            }
203            if stderr.contains("AADSTS") {
204                // Extract the first AADSTS error line for a concise message
205                let detail = stderr
206                    .lines()
207                    .find(|l| l.contains("AADSTS"))
208                    .unwrap_or(&stderr)
209                    .trim();
210                return Err(AuthError::TokenError(format!(
211                    "Failed to get access token for {}: {}\n  \
212                     Debug: az account get-access-token --resource {}\n  \
213                     Fix: Ensure 'Cognitive Services User' role is assigned on the AI Services resource",
214                    self.resource_scope, detail, self.resource_scope
215                )));
216            }
217            return Err(AuthError::TokenError(token_error_detail(
218                &stderr,
219                output.status,
220            )));
221        }
222
223        let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
224        if token.is_empty() {
225            return Err(AuthError::TokenError("Empty token received".to_string()));
226        }
227
228        Ok(token)
229    }
230
231    fn method_name(&self) -> &'static str {
232        "Azure CLI"
233    }
234}
235
236/// Environment variable authentication provider
237#[derive(Debug)]
238pub struct EnvAuth {
239    client_id: String,
240    client_secret: String,
241    tenant_id: String,
242    resource_scope: &'static str,
243}
244
245impl EnvAuth {
246    /// Create from environment variables (defaults to Search scope)
247    pub fn from_env() -> Result<Self, AuthError> {
248        Self::from_env_for_scope("https://search.azure.com")
249    }
250
251    /// Create from environment variables for a specific resource scope
252    pub fn from_env_for_scope(scope: &'static str) -> Result<Self, AuthError> {
253        let client_id = std::env::var("AZURE_CLIENT_ID")
254            .map_err(|_| AuthError::MissingEnvVar("AZURE_CLIENT_ID".to_string()))?;
255        let client_secret = std::env::var("AZURE_CLIENT_SECRET")
256            .map_err(|_| AuthError::MissingEnvVar("AZURE_CLIENT_SECRET".to_string()))?;
257        let tenant_id = std::env::var("AZURE_TENANT_ID")
258            .map_err(|_| AuthError::MissingEnvVar("AZURE_TENANT_ID".to_string()))?;
259
260        Ok(Self {
261            client_id,
262            client_secret,
263            tenant_id,
264            resource_scope: scope,
265        })
266    }
267
268    /// Check if environment variables are set
269    pub fn is_configured() -> bool {
270        std::env::var("AZURE_CLIENT_ID").is_ok()
271            && std::env::var("AZURE_CLIENT_SECRET").is_ok()
272            && std::env::var("AZURE_TENANT_ID").is_ok()
273    }
274}
275
276impl AuthProvider for EnvAuth {
277    fn get_token(&self) -> Result<String, AuthError> {
278        // Use Azure CLI to get token with service principal
279        let output = Command::new("az")
280            .args([
281                "account",
282                "get-access-token",
283                "--resource",
284                self.resource_scope,
285                "--query",
286                "accessToken",
287                "--output",
288                "tsv",
289                "--tenant",
290                &self.tenant_id,
291                "--username",
292                &self.client_id,
293            ])
294            .env("AZURE_CLIENT_SECRET", &self.client_secret)
295            .output()
296            .map_err(|e| AuthError::TokenError(e.to_string()))?;
297
298        if !output.status.success() {
299            let stderr = String::from_utf8_lossy(&output.stderr);
300            return Err(AuthError::AuthFailed(stderr.to_string()));
301        }
302
303        let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
304        Ok(token)
305    }
306
307    fn method_name(&self) -> &'static str {
308        "Environment Variables (Service Principal)"
309    }
310}
311
312/// Authentication status
313#[derive(Debug, Clone)]
314pub struct AuthStatus {
315    pub logged_in: bool,
316    pub user: Option<String>,
317    pub subscription: Option<String>,
318    pub subscription_id: Option<String>,
319}
320
321/// Get the best available authentication provider for Search (backward compat)
322pub fn get_auth_provider() -> Result<Box<dyn AuthProvider>, AuthError> {
323    get_auth_provider_for_scope("https://search.azure.com")
324}
325
326/// Get the best available authentication provider for a specific service domain
327pub fn get_auth_provider_for(
328    domain: rigg_core::ServiceDomain,
329) -> Result<Box<dyn AuthProvider>, AuthError> {
330    let scope = match domain {
331        rigg_core::ServiceDomain::Search => "https://search.azure.com",
332        rigg_core::ServiceDomain::Foundry => "https://ai.azure.com",
333    };
334    get_auth_provider_for_scope(scope)
335}
336
337/// Get the best available authentication provider for Azure Cognitive Services (OpenAI)
338pub fn get_cognitive_services_auth() -> Result<Box<dyn AuthProvider>, AuthError> {
339    get_auth_provider_for_scope("https://cognitiveservices.azure.com")
340}
341
342/// Static bearer token from the environment (`RIGG_ACCESS_TOKEN`).
343/// Useful for CI systems that mint tokens out-of-band, and for tests.
344pub struct StaticTokenAuth {
345    token: String,
346}
347
348impl AuthProvider for StaticTokenAuth {
349    fn get_token(&self) -> Result<String, AuthError> {
350        Ok(self.token.clone())
351    }
352    fn method_name(&self) -> &'static str {
353        "Static token (RIGG_ACCESS_TOKEN)"
354    }
355}
356
357/// Get the best available authentication provider for a specific resource scope
358fn get_auth_provider_for_scope(scope: &'static str) -> Result<Box<dyn AuthProvider>, AuthError> {
359    // A pre-minted token wins over everything.
360    if let Ok(token) = std::env::var("RIGG_ACCESS_TOKEN") {
361        if !token.is_empty() {
362            return Ok(Box::new(StaticTokenAuth { token }));
363        }
364    }
365    // First try environment variables
366    if EnvAuth::is_configured() {
367        return Ok(Box::new(EnvAuth::from_env_for_scope(scope)?));
368    }
369
370    // Fall back to Azure CLI
371    AzCliAuth::check_status()?;
372    Ok(Box::new(AzCliAuth {
373        resource_scope: scope,
374    }))
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380    use std::sync::Mutex;
381
382    // Env var tests must run serially since they share process-wide state.
383    static ENV_MUTEX: Mutex<()> = Mutex::new(());
384
385    /// # Safety
386    /// Must be called while holding ENV_MUTEX to avoid data races.
387    unsafe fn clear_azure_env_vars() {
388        unsafe {
389            std::env::remove_var("AZURE_CLIENT_ID");
390            std::env::remove_var("AZURE_CLIENT_SECRET");
391            std::env::remove_var("AZURE_TENANT_ID");
392        }
393    }
394
395    /// # Safety
396    /// Must be called while holding ENV_MUTEX to avoid data races.
397    unsafe fn set_azure_env_vars() {
398        unsafe {
399            std::env::set_var("AZURE_CLIENT_ID", "test-client-id");
400            std::env::set_var("AZURE_CLIENT_SECRET", "test-client-secret");
401            std::env::set_var("AZURE_TENANT_ID", "test-tenant-id");
402        }
403    }
404
405    #[test]
406    fn test_env_auth_from_env_success() {
407        let _lock = ENV_MUTEX.lock().unwrap();
408        unsafe { set_azure_env_vars() };
409
410        let result = EnvAuth::from_env();
411        assert!(result.is_ok());
412        let auth = result.unwrap();
413        assert_eq!(auth.client_id, "test-client-id");
414        assert_eq!(auth.client_secret, "test-client-secret");
415        assert_eq!(auth.tenant_id, "test-tenant-id");
416
417        unsafe { clear_azure_env_vars() };
418    }
419
420    #[test]
421    fn test_env_auth_from_env_missing_client_id() {
422        let _lock = ENV_MUTEX.lock().unwrap();
423        unsafe {
424            clear_azure_env_vars();
425            std::env::set_var("AZURE_CLIENT_SECRET", "test-secret");
426            std::env::set_var("AZURE_TENANT_ID", "test-tenant");
427        }
428
429        let result = EnvAuth::from_env();
430        assert!(result.is_err());
431        let err = result.unwrap_err();
432        assert!(matches!(err, AuthError::MissingEnvVar(ref v) if v == "AZURE_CLIENT_ID"));
433
434        unsafe { clear_azure_env_vars() };
435    }
436
437    #[test]
438    fn test_env_auth_from_env_missing_client_secret() {
439        let _lock = ENV_MUTEX.lock().unwrap();
440        unsafe {
441            clear_azure_env_vars();
442            std::env::set_var("AZURE_CLIENT_ID", "test-id");
443            std::env::set_var("AZURE_TENANT_ID", "test-tenant");
444        }
445
446        let result = EnvAuth::from_env();
447        assert!(result.is_err());
448        let err = result.unwrap_err();
449        assert!(matches!(err, AuthError::MissingEnvVar(ref v) if v == "AZURE_CLIENT_SECRET"));
450
451        unsafe { clear_azure_env_vars() };
452    }
453
454    #[test]
455    fn test_env_auth_from_env_missing_tenant_id() {
456        let _lock = ENV_MUTEX.lock().unwrap();
457        unsafe {
458            clear_azure_env_vars();
459            std::env::set_var("AZURE_CLIENT_ID", "test-id");
460            std::env::set_var("AZURE_CLIENT_SECRET", "test-secret");
461        }
462
463        let result = EnvAuth::from_env();
464        assert!(result.is_err());
465        let err = result.unwrap_err();
466        assert!(matches!(err, AuthError::MissingEnvVar(ref v) if v == "AZURE_TENANT_ID"));
467
468        unsafe { clear_azure_env_vars() };
469    }
470
471    #[test]
472    fn test_env_auth_is_configured_all_set() {
473        let _lock = ENV_MUTEX.lock().unwrap();
474        unsafe { set_azure_env_vars() };
475
476        assert!(EnvAuth::is_configured());
477
478        unsafe { clear_azure_env_vars() };
479    }
480
481    #[test]
482    fn test_env_auth_is_configured_none_set() {
483        let _lock = ENV_MUTEX.lock().unwrap();
484        unsafe { clear_azure_env_vars() };
485
486        assert!(!EnvAuth::is_configured());
487    }
488
489    #[test]
490    fn test_env_auth_is_configured_partial() {
491        let _lock = ENV_MUTEX.lock().unwrap();
492        unsafe {
493            clear_azure_env_vars();
494            std::env::set_var("AZURE_CLIENT_ID", "test-id");
495            std::env::set_var("AZURE_CLIENT_SECRET", "test-secret");
496        }
497        // AZURE_TENANT_ID intentionally missing
498
499        assert!(!EnvAuth::is_configured());
500
501        unsafe { clear_azure_env_vars() };
502    }
503
504    #[test]
505    fn test_env_auth_method_name() {
506        let _lock = ENV_MUTEX.lock().unwrap();
507        unsafe { set_azure_env_vars() };
508
509        let auth = EnvAuth::from_env().unwrap();
510        assert_eq!(
511            auth.method_name(),
512            "Environment Variables (Service Principal)"
513        );
514
515        unsafe { clear_azure_env_vars() };
516    }
517
518    #[test]
519    fn test_az_cli_auth_method_name() {
520        let auth = AzCliAuth::new();
521        assert_eq!(auth.method_name(), "Azure CLI");
522    }
523
524    #[test]
525    fn test_az_cli_auth_search_scope() {
526        let auth = AzCliAuth::for_search();
527        assert_eq!(auth.resource_scope, "https://search.azure.com");
528    }
529
530    #[test]
531    fn test_az_cli_auth_foundry_scope() {
532        let auth = AzCliAuth::for_foundry();
533        assert_eq!(auth.resource_scope, "https://ai.azure.com");
534    }
535
536    #[test]
537    fn test_az_cli_auth_cognitive_services_scope() {
538        let auth = AzCliAuth::for_cognitive_services();
539        assert_eq!(auth.resource_scope, "https://cognitiveservices.azure.com");
540    }
541
542    #[test]
543    fn test_az_cli_auth_new_defaults_to_search() {
544        let auth = AzCliAuth::new();
545        assert_eq!(auth.resource_scope, "https://search.azure.com");
546    }
547
548    #[test]
549    fn test_env_auth_from_env_scope_foundry() {
550        let _lock = ENV_MUTEX.lock().unwrap();
551        unsafe { set_azure_env_vars() };
552
553        let result = EnvAuth::from_env_for_scope("https://ai.azure.com");
554        assert!(result.is_ok());
555        let auth = result.unwrap();
556        assert_eq!(auth.resource_scope, "https://ai.azure.com");
557
558        unsafe { clear_azure_env_vars() };
559    }
560
561    #[test]
562    fn test_env_auth_from_env_default_scope_is_search() {
563        let _lock = ENV_MUTEX.lock().unwrap();
564        unsafe { set_azure_env_vars() };
565
566        let auth = EnvAuth::from_env().unwrap();
567        assert_eq!(auth.resource_scope, "https://search.azure.com");
568
569        unsafe { clear_azure_env_vars() };
570    }
571
572    #[test]
573    fn test_auth_status_fields() {
574        let status = AuthStatus {
575            logged_in: true,
576            user: Some("testuser@example.com".to_string()),
577            subscription: Some("My Subscription".to_string()),
578            subscription_id: Some("00000000-0000-0000-0000-000000000000".to_string()),
579        };
580
581        assert!(status.logged_in);
582        assert_eq!(status.user.as_deref(), Some("testuser@example.com"));
583        assert_eq!(status.subscription.as_deref(), Some("My Subscription"));
584        assert_eq!(
585            status.subscription_id.as_deref(),
586            Some("00000000-0000-0000-0000-000000000000")
587        );
588    }
589
590    #[test]
591    fn test_for_cosmos_uses_cosmos_scope() {
592        let auth = AzCliAuth::for_cosmos();
593        assert_eq!(auth.resource_scope, "https://cosmos.azure.com");
594    }
595
596    #[test]
597    fn account_parse_error_is_actionable_not_raw_serde() {
598        let serde_err = serde_json::from_slice::<serde_json::Value>(b"").unwrap_err();
599        let err = account_parse_error(serde_err);
600        match err {
601            AuthError::TokenError(msg) => {
602                assert!(msg.contains("az login"), "{msg}");
603                assert!(msg.contains("transient"), "{msg}");
604                assert!(msg.contains("az account show"), "{msg}");
605            }
606            other => panic!("expected TokenError, got {other:?}"),
607        }
608    }
609
610    #[test]
611    fn token_error_detail_falls_back_when_stderr_empty() {
612        let status = std::process::Command::new("true")
613            .status()
614            .expect("failed to run `true`");
615        let detail = token_error_detail("   \n", status);
616        assert!(detail.contains("az login"), "{detail}");
617        assert!(detail.contains("transient"), "{detail}");
618        assert!(!detail.trim().is_empty());
619    }
620
621    #[test]
622    fn token_error_detail_preserves_nonempty_stderr() {
623        let status = std::process::Command::new("false")
624            .status()
625            .expect("failed to run `false`");
626        let detail = token_error_detail("  ERROR: something specific broke  ", status);
627        assert_eq!(detail, "ERROR: something specific broke");
628    }
629}