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
23pub trait AuthProvider: Send + Sync {
25 fn get_token(&self) -> Result<String, AuthError>;
27
28 fn method_name(&self) -> &'static str;
30}
31
32pub struct AzCliAuth {
34 resource_scope: &'static str,
35}
36
37impl AzCliAuth {
38 pub fn for_search() -> Self {
40 Self {
41 resource_scope: "https://search.azure.com",
42 }
43 }
44
45 pub fn for_foundry() -> Self {
47 Self {
48 resource_scope: "https://ai.azure.com",
49 }
50 }
51
52 pub fn for_cognitive_services() -> Self {
54 Self {
55 resource_scope: "https://cognitiveservices.azure.com",
56 }
57 }
58
59 pub fn new() -> Self {
61 Self::for_search()
62 }
63
64 pub fn check_status() -> Result<AuthStatus, AuthError> {
66 let version_output = Command::new("az").arg("--version").output();
68
69 if version_output.is_err() {
70 return Err(AuthError::AzCliNotFound);
71 }
72
73 let account_output = Command::new("az")
75 .args(["account", "show", "--output", "json"])
76 .output()
77 .map_err(|e| AuthError::TokenError(e.to_string()))?;
78
79 if !account_output.status.success() {
80 return Err(AuthError::NotLoggedIn);
81 }
82
83 let account_json: serde_json::Value = serde_json::from_slice(&account_output.stdout)
85 .map_err(|e| AuthError::TokenError(e.to_string()))?;
86
87 Ok(AuthStatus {
88 logged_in: true,
89 user: account_json
90 .get("user")
91 .and_then(|u| u.get("name"))
92 .and_then(|n| n.as_str())
93 .map(String::from),
94 subscription: account_json
95 .get("name")
96 .and_then(|n| n.as_str())
97 .map(String::from),
98 subscription_id: account_json
99 .get("id")
100 .and_then(|i| i.as_str())
101 .map(String::from),
102 })
103 }
104
105 pub fn get_arm_token() -> Result<String, AuthError> {
107 let output = Command::new("az")
108 .args([
109 "account",
110 "get-access-token",
111 "--resource",
112 "https://management.azure.com",
113 "--query",
114 "accessToken",
115 "--output",
116 "tsv",
117 ])
118 .output()
119 .map_err(|e| AuthError::TokenError(e.to_string()))?;
120
121 if !output.status.success() {
122 let stderr = String::from_utf8_lossy(&output.stderr);
123 if stderr.contains("not logged in") || stderr.contains("AADSTS") {
124 return Err(AuthError::NotLoggedIn);
125 }
126 return Err(AuthError::TokenError(stderr.to_string()));
127 }
128
129 let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
130 if token.is_empty() {
131 return Err(AuthError::TokenError(
132 "Empty ARM token received".to_string(),
133 ));
134 }
135
136 Ok(token)
137 }
138}
139
140impl Default for AzCliAuth {
141 fn default() -> Self {
142 Self::new()
143 }
144}
145
146impl AuthProvider for AzCliAuth {
147 fn get_token(&self) -> Result<String, AuthError> {
148 let output = Command::new("az")
149 .args([
150 "account",
151 "get-access-token",
152 "--resource",
153 self.resource_scope,
154 "--query",
155 "accessToken",
156 "--output",
157 "tsv",
158 ])
159 .output()
160 .map_err(|e| AuthError::TokenError(e.to_string()))?;
161
162 if !output.status.success() {
163 let stderr = String::from_utf8_lossy(&output.stderr);
164 if stderr.contains("not logged in") {
165 return Err(AuthError::NotLoggedIn);
166 }
167 if stderr.contains("AADSTS") {
168 let detail = stderr
170 .lines()
171 .find(|l| l.contains("AADSTS"))
172 .unwrap_or(&stderr)
173 .trim();
174 return Err(AuthError::TokenError(format!(
175 "Failed to get access token for {}: {}\n \
176 Debug: az account get-access-token --resource {}\n \
177 Fix: Ensure 'Cognitive Services User' role is assigned on the AI Services resource",
178 self.resource_scope, detail, self.resource_scope
179 )));
180 }
181 return Err(AuthError::TokenError(stderr.to_string()));
182 }
183
184 let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
185 if token.is_empty() {
186 return Err(AuthError::TokenError("Empty token received".to_string()));
187 }
188
189 Ok(token)
190 }
191
192 fn method_name(&self) -> &'static str {
193 "Azure CLI"
194 }
195}
196
197#[derive(Debug)]
199pub struct EnvAuth {
200 client_id: String,
201 client_secret: String,
202 tenant_id: String,
203 resource_scope: &'static str,
204}
205
206impl EnvAuth {
207 pub fn from_env() -> Result<Self, AuthError> {
209 Self::from_env_for_scope("https://search.azure.com")
210 }
211
212 pub fn from_env_for_scope(scope: &'static str) -> Result<Self, AuthError> {
214 let client_id = std::env::var("AZURE_CLIENT_ID")
215 .map_err(|_| AuthError::MissingEnvVar("AZURE_CLIENT_ID".to_string()))?;
216 let client_secret = std::env::var("AZURE_CLIENT_SECRET")
217 .map_err(|_| AuthError::MissingEnvVar("AZURE_CLIENT_SECRET".to_string()))?;
218 let tenant_id = std::env::var("AZURE_TENANT_ID")
219 .map_err(|_| AuthError::MissingEnvVar("AZURE_TENANT_ID".to_string()))?;
220
221 Ok(Self {
222 client_id,
223 client_secret,
224 tenant_id,
225 resource_scope: scope,
226 })
227 }
228
229 pub fn is_configured() -> bool {
231 std::env::var("AZURE_CLIENT_ID").is_ok()
232 && std::env::var("AZURE_CLIENT_SECRET").is_ok()
233 && std::env::var("AZURE_TENANT_ID").is_ok()
234 }
235}
236
237impl AuthProvider for EnvAuth {
238 fn get_token(&self) -> Result<String, AuthError> {
239 let output = Command::new("az")
241 .args([
242 "account",
243 "get-access-token",
244 "--resource",
245 self.resource_scope,
246 "--query",
247 "accessToken",
248 "--output",
249 "tsv",
250 "--tenant",
251 &self.tenant_id,
252 "--username",
253 &self.client_id,
254 ])
255 .env("AZURE_CLIENT_SECRET", &self.client_secret)
256 .output()
257 .map_err(|e| AuthError::TokenError(e.to_string()))?;
258
259 if !output.status.success() {
260 let stderr = String::from_utf8_lossy(&output.stderr);
261 return Err(AuthError::AuthFailed(stderr.to_string()));
262 }
263
264 let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
265 Ok(token)
266 }
267
268 fn method_name(&self) -> &'static str {
269 "Environment Variables (Service Principal)"
270 }
271}
272
273#[derive(Debug, Clone)]
275pub struct AuthStatus {
276 pub logged_in: bool,
277 pub user: Option<String>,
278 pub subscription: Option<String>,
279 pub subscription_id: Option<String>,
280}
281
282pub fn get_auth_provider() -> Result<Box<dyn AuthProvider>, AuthError> {
284 get_auth_provider_for_scope("https://search.azure.com")
285}
286
287pub fn get_auth_provider_for(
289 domain: rigg_core::ServiceDomain,
290) -> Result<Box<dyn AuthProvider>, AuthError> {
291 let scope = match domain {
292 rigg_core::ServiceDomain::Search => "https://search.azure.com",
293 rigg_core::ServiceDomain::Foundry => "https://ai.azure.com",
294 };
295 get_auth_provider_for_scope(scope)
296}
297
298pub fn get_cognitive_services_auth() -> Result<Box<dyn AuthProvider>, AuthError> {
300 get_auth_provider_for_scope("https://cognitiveservices.azure.com")
301}
302
303fn get_auth_provider_for_scope(scope: &'static str) -> Result<Box<dyn AuthProvider>, AuthError> {
305 if EnvAuth::is_configured() {
307 return Ok(Box::new(EnvAuth::from_env_for_scope(scope)?));
308 }
309
310 AzCliAuth::check_status()?;
312 Ok(Box::new(AzCliAuth {
313 resource_scope: scope,
314 }))
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320 use std::sync::Mutex;
321
322 static ENV_MUTEX: Mutex<()> = Mutex::new(());
324
325 unsafe fn clear_azure_env_vars() {
328 unsafe {
329 std::env::remove_var("AZURE_CLIENT_ID");
330 std::env::remove_var("AZURE_CLIENT_SECRET");
331 std::env::remove_var("AZURE_TENANT_ID");
332 }
333 }
334
335 unsafe fn set_azure_env_vars() {
338 unsafe {
339 std::env::set_var("AZURE_CLIENT_ID", "test-client-id");
340 std::env::set_var("AZURE_CLIENT_SECRET", "test-client-secret");
341 std::env::set_var("AZURE_TENANT_ID", "test-tenant-id");
342 }
343 }
344
345 #[test]
346 fn test_env_auth_from_env_success() {
347 let _lock = ENV_MUTEX.lock().unwrap();
348 unsafe { set_azure_env_vars() };
349
350 let result = EnvAuth::from_env();
351 assert!(result.is_ok());
352 let auth = result.unwrap();
353 assert_eq!(auth.client_id, "test-client-id");
354 assert_eq!(auth.client_secret, "test-client-secret");
355 assert_eq!(auth.tenant_id, "test-tenant-id");
356
357 unsafe { clear_azure_env_vars() };
358 }
359
360 #[test]
361 fn test_env_auth_from_env_missing_client_id() {
362 let _lock = ENV_MUTEX.lock().unwrap();
363 unsafe {
364 clear_azure_env_vars();
365 std::env::set_var("AZURE_CLIENT_SECRET", "test-secret");
366 std::env::set_var("AZURE_TENANT_ID", "test-tenant");
367 }
368
369 let result = EnvAuth::from_env();
370 assert!(result.is_err());
371 let err = result.unwrap_err();
372 assert!(matches!(err, AuthError::MissingEnvVar(ref v) if v == "AZURE_CLIENT_ID"));
373
374 unsafe { clear_azure_env_vars() };
375 }
376
377 #[test]
378 fn test_env_auth_from_env_missing_client_secret() {
379 let _lock = ENV_MUTEX.lock().unwrap();
380 unsafe {
381 clear_azure_env_vars();
382 std::env::set_var("AZURE_CLIENT_ID", "test-id");
383 std::env::set_var("AZURE_TENANT_ID", "test-tenant");
384 }
385
386 let result = EnvAuth::from_env();
387 assert!(result.is_err());
388 let err = result.unwrap_err();
389 assert!(matches!(err, AuthError::MissingEnvVar(ref v) if v == "AZURE_CLIENT_SECRET"));
390
391 unsafe { clear_azure_env_vars() };
392 }
393
394 #[test]
395 fn test_env_auth_from_env_missing_tenant_id() {
396 let _lock = ENV_MUTEX.lock().unwrap();
397 unsafe {
398 clear_azure_env_vars();
399 std::env::set_var("AZURE_CLIENT_ID", "test-id");
400 std::env::set_var("AZURE_CLIENT_SECRET", "test-secret");
401 }
402
403 let result = EnvAuth::from_env();
404 assert!(result.is_err());
405 let err = result.unwrap_err();
406 assert!(matches!(err, AuthError::MissingEnvVar(ref v) if v == "AZURE_TENANT_ID"));
407
408 unsafe { clear_azure_env_vars() };
409 }
410
411 #[test]
412 fn test_env_auth_is_configured_all_set() {
413 let _lock = ENV_MUTEX.lock().unwrap();
414 unsafe { set_azure_env_vars() };
415
416 assert!(EnvAuth::is_configured());
417
418 unsafe { clear_azure_env_vars() };
419 }
420
421 #[test]
422 fn test_env_auth_is_configured_none_set() {
423 let _lock = ENV_MUTEX.lock().unwrap();
424 unsafe { clear_azure_env_vars() };
425
426 assert!(!EnvAuth::is_configured());
427 }
428
429 #[test]
430 fn test_env_auth_is_configured_partial() {
431 let _lock = ENV_MUTEX.lock().unwrap();
432 unsafe {
433 clear_azure_env_vars();
434 std::env::set_var("AZURE_CLIENT_ID", "test-id");
435 std::env::set_var("AZURE_CLIENT_SECRET", "test-secret");
436 }
437 assert!(!EnvAuth::is_configured());
440
441 unsafe { clear_azure_env_vars() };
442 }
443
444 #[test]
445 fn test_env_auth_method_name() {
446 let _lock = ENV_MUTEX.lock().unwrap();
447 unsafe { set_azure_env_vars() };
448
449 let auth = EnvAuth::from_env().unwrap();
450 assert_eq!(
451 auth.method_name(),
452 "Environment Variables (Service Principal)"
453 );
454
455 unsafe { clear_azure_env_vars() };
456 }
457
458 #[test]
459 fn test_az_cli_auth_method_name() {
460 let auth = AzCliAuth::new();
461 assert_eq!(auth.method_name(), "Azure CLI");
462 }
463
464 #[test]
465 fn test_az_cli_auth_search_scope() {
466 let auth = AzCliAuth::for_search();
467 assert_eq!(auth.resource_scope, "https://search.azure.com");
468 }
469
470 #[test]
471 fn test_az_cli_auth_foundry_scope() {
472 let auth = AzCliAuth::for_foundry();
473 assert_eq!(auth.resource_scope, "https://ai.azure.com");
474 }
475
476 #[test]
477 fn test_az_cli_auth_cognitive_services_scope() {
478 let auth = AzCliAuth::for_cognitive_services();
479 assert_eq!(auth.resource_scope, "https://cognitiveservices.azure.com");
480 }
481
482 #[test]
483 fn test_az_cli_auth_new_defaults_to_search() {
484 let auth = AzCliAuth::new();
485 assert_eq!(auth.resource_scope, "https://search.azure.com");
486 }
487
488 #[test]
489 fn test_env_auth_from_env_scope_foundry() {
490 let _lock = ENV_MUTEX.lock().unwrap();
491 unsafe { set_azure_env_vars() };
492
493 let result = EnvAuth::from_env_for_scope("https://ai.azure.com");
494 assert!(result.is_ok());
495 let auth = result.unwrap();
496 assert_eq!(auth.resource_scope, "https://ai.azure.com");
497
498 unsafe { clear_azure_env_vars() };
499 }
500
501 #[test]
502 fn test_env_auth_from_env_default_scope_is_search() {
503 let _lock = ENV_MUTEX.lock().unwrap();
504 unsafe { set_azure_env_vars() };
505
506 let auth = EnvAuth::from_env().unwrap();
507 assert_eq!(auth.resource_scope, "https://search.azure.com");
508
509 unsafe { clear_azure_env_vars() };
510 }
511
512 #[test]
513 fn test_auth_status_fields() {
514 let status = AuthStatus {
515 logged_in: true,
516 user: Some("testuser@example.com".to_string()),
517 subscription: Some("My Subscription".to_string()),
518 subscription_id: Some("00000000-0000-0000-0000-000000000000".to_string()),
519 };
520
521 assert!(status.logged_in);
522 assert_eq!(status.user.as_deref(), Some("testuser@example.com"));
523 assert_eq!(status.subscription.as_deref(), Some("My Subscription"));
524 assert_eq!(
525 status.subscription_id.as_deref(),
526 Some("00000000-0000-0000-0000-000000000000")
527 );
528 }
529}