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