1use std::process::Command;
4use thiserror::Error;
5
6#[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
23fn 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
36fn 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
49pub trait AuthProvider: Send + Sync {
51 fn get_token(&self) -> Result<String, AuthError>;
53
54 fn method_name(&self) -> &'static str;
56}
57
58pub struct AzCliAuth {
60 resource_scope: &'static str,
61}
62
63impl AzCliAuth {
64 pub fn for_search() -> Self {
66 Self {
67 resource_scope: "https://search.azure.com",
68 }
69 }
70
71 pub fn for_foundry() -> Self {
73 Self {
74 resource_scope: "https://ai.azure.com",
75 }
76 }
77
78 pub fn for_cognitive_services() -> Self {
80 Self {
81 resource_scope: "https://cognitiveservices.azure.com",
82 }
83 }
84
85 pub fn for_cosmos() -> Self {
87 Self {
88 resource_scope: "https://cosmos.azure.com",
89 }
90 }
91
92 pub fn new() -> Self {
94 Self::for_search()
95 }
96
97 pub fn check_status() -> Result<AuthStatus, AuthError> {
99 let version_output = Command::new("az").arg("--version").output();
101
102 if version_output.is_err() {
103 return Err(AuthError::AzCliNotFound);
104 }
105
106 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 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 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 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#[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 pub fn from_env() -> Result<Self, AuthError> {
248 Self::from_env_for_scope("https://search.azure.com")
249 }
250
251 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 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 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#[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
321pub fn get_auth_provider() -> Result<Box<dyn AuthProvider>, AuthError> {
323 get_auth_provider_for_scope("https://search.azure.com")
324}
325
326pub 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
337pub fn get_cognitive_services_auth() -> Result<Box<dyn AuthProvider>, AuthError> {
339 get_auth_provider_for_scope("https://cognitiveservices.azure.com")
340}
341
342pub 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
357fn get_auth_provider_for_scope(scope: &'static str) -> Result<Box<dyn AuthProvider>, AuthError> {
359 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 if EnvAuth::is_configured() {
367 return Ok(Box::new(EnvAuth::from_env_for_scope(scope)?));
368 }
369
370 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 static ENV_MUTEX: Mutex<()> = Mutex::new(());
384
385 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 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 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}