1use super::CloudProviderId;
5
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub enum CloudLoginChallenge {
8 DeviceCode {
9 verification_uri: String,
10 user_code: String,
11 },
12 AuthorizationUrl {
13 url: String,
14 },
15}
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum CloudSelectionKind {
19 Account,
20 Role,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct CloudLoginChoice {
25 pub id: String,
26 pub label: String,
27}
28impl CloudLoginChoice {
29 pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
30 Self {
31 id: id.into(),
32 label: label.into(),
33 }
34 }
35}
36
37pub trait CloudLoginUi {
39 fn present_challenge(&mut self, challenge: CloudLoginChallenge);
40 fn prompt_config(
43 &mut self,
44 variable: &'static str,
45 label: &'static str,
46 ) -> Result<String, String>;
47 fn select(
48 &mut self,
49 kind: CloudSelectionKind,
50 choices: &[CloudLoginChoice],
51 ) -> Result<String, String>;
52}
53
54pub const fn supports_login(provider: CloudProviderId) -> bool {
56 match provider {
57 CloudProviderId::AwsBedrock
58 | CloudProviderId::AzureOpenAi
59 | CloudProviderId::GoogleVertex => true,
60 }
61}
62
63pub async fn login(provider: CloudProviderId, ui: &mut dyn CloudLoginUi) -> Result<(), String> {
65 match provider {
66 CloudProviderId::AwsBedrock => login_aws_bedrock(ui).await,
67 CloudProviderId::AzureOpenAi => login_azure_openai(ui).await,
68 CloudProviderId::GoogleVertex => login_google_vertex(ui).await,
69 }
70}
71
72fn required_env(name: &str) -> Result<String, String> {
73 std::env::var(name)
74 .ok()
75 .filter(|v| !v.trim().is_empty())
76 .ok_or_else(|| format!("missing {name}"))
77}
78
79async fn login_azure_openai(ui: &mut dyn CloudLoginUi) -> Result<(), String> {
80 use super::azure_openai::{
81 device_code_request, refresh_request, AzureAudience, AzureRegistration,
82 };
83 let client_id = std::env::var("SYNAPS_AZURE_CLIENT_ID")
84 .ok()
85 .filter(|v| !v.trim().is_empty())
86 .ok_or("registration_required: configure SYNAPS_AZURE_CLIENT_ID")?;
87 let config = super::AzureOpenAiConfig::new(
88 required_env("SYNAPS_AZURE_TENANT")?,
89 required_env("SYNAPS_AZURE_SUBSCRIPTION_ID")?,
90 required_env("SYNAPS_AZURE_RESOURCE_GROUP")?,
91 required_env("SYNAPS_AZURE_RESOURCE_NAME")?,
92 std::env::var("SYNAPS_AZURE_DEPLOYMENT").unwrap_or_else(|_| "default".into()),
93 )?;
94 let reg = AzureRegistration::production(Some(client_id.clone())).map_err(|e| e.to_string())?;
95 let http = reqwest::Client::builder()
96 .redirect(reqwest::redirect::Policy::none())
97 .build()
98 .map_err(|e| e.to_string())?;
99 let req = device_code_request(&config, ®).map_err(|e| e.to_string())?;
100 let d: serde_json::Value = http
101 .post(req.url)
102 .form(&req.form)
103 .send()
104 .await
105 .map_err(|_| "Azure device authorization failed")?
106 .json()
107 .await
108 .map_err(|_| "invalid Azure device response")?;
109 ui.present_challenge(CloudLoginChallenge::DeviceCode {
110 verification_uri: d["verification_uri"]
111 .as_str()
112 .unwrap_or("Microsoft sign-in")
113 .into(),
114 user_code: d["user_code"]
115 .as_str()
116 .unwrap_or("(provided by Microsoft)")
117 .into(),
118 });
119 let device = d["device_code"]
120 .as_str()
121 .ok_or("invalid Azure device response")?;
122 let token_url = format!(
123 "https://login.microsoftonline.com/{}/oauth2/v2.0/token",
124 config.tenant
125 );
126 let mut interval = d["interval"].as_u64().unwrap_or(5).max(1);
127 let deadline = tokio::time::Instant::now()
128 + std::time::Duration::from_secs(d["expires_in"].as_u64().unwrap_or(900).min(3600));
129 let arm = loop {
130 if tokio::time::Instant::now() >= deadline {
131 return Err("Azure device code expired".into());
132 }
133 tokio::select! {
134 _ = tokio::signal::ctrl_c() => return Err("Azure device authorization cancelled".into()),
135 _ = tokio::time::sleep(std::time::Duration::from_secs(interval)) => {}
136 }
137 let r = http
138 .post(&token_url)
139 .form(&[
140 ("client_id", client_id.as_str()),
141 ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
142 ("device_code", device),
143 ])
144 .send()
145 .await
146 .map_err(|_| "Azure token polling failed")?;
147 let v: serde_json::Value = r.json().await.map_err(|_| "invalid Azure token response")?;
148 if v["access_token"].as_str().is_some() {
149 break v;
150 }
151 match v["error"].as_str() {
152 Some("authorization_pending") => {}
153 Some("slow_down") => interval += 5,
154 Some(e) => return Err(format!("Azure authorization failed: {e}")),
155 None => return Err("invalid Azure token response".into()),
156 }
157 };
158 let refresh = arm["refresh_token"]
159 .as_str()
160 .ok_or("Azure did not issue a refresh token")?
161 .to_owned();
162 let rr = refresh_request(&config, ®, AzureAudience::Inference, &refresh)
163 .map_err(|e| e.to_string())?;
164 let infer: serde_json::Value = http
165 .post(rr.url)
166 .form(&rr.form)
167 .send()
168 .await
169 .map_err(|_| "Azure inference token failed")?
170 .json()
171 .await
172 .map_err(|_| "invalid Azure inference token")?;
173 let now = std::time::SystemTime::now()
174 .duration_since(std::time::UNIX_EPOCH)
175 .unwrap_or_default()
176 .as_millis() as u64;
177 let account_url = format!(
180 "https://management.azure.com/subscriptions/{}/resourceGroups/{}/providers/Microsoft.CognitiveServices/accounts/{}?api-version=2023-05-01",
181 config.subscription_id, config.resource_group, config.resource_name
182 );
183 let account: serde_json::Value = http
184 .get(account_url)
185 .bearer_auth(
186 arm["access_token"]
187 .as_str()
188 .ok_or("invalid Azure ARM token")?,
189 )
190 .send()
191 .await
192 .map_err(|_| "Azure ARM endpoint resolution failed")?
193 .error_for_status()
194 .map_err(|_| "Azure ARM resource validation failed")?
195 .json()
196 .await
197 .map_err(|_| "invalid Azure ARM resource metadata")?;
198 let endpoint = account
199 .pointer("/properties/endpoint")
200 .and_then(|v| v.as_str())
201 .ok_or("Azure ARM metadata omitted the resource endpoint")?;
202 super::azure_openai::AzureEndpoint::parse(endpoint)
203 .map_err(|_| "Azure ARM returned an untrusted resource endpoint")?;
204 let deployment_url = format!(
207 "https://management.azure.com/subscriptions/{}/resourceGroups/{}/providers/Microsoft.CognitiveServices/accounts/{}/deployments?api-version=2023-05-01",
208 config.subscription_id, config.resource_group, config.resource_name
209 );
210 let deployments: serde_json::Value = http
211 .get(deployment_url)
212 .bearer_auth(
213 arm["access_token"]
214 .as_str()
215 .ok_or("invalid Azure ARM token")?,
216 )
217 .send()
218 .await
219 .map_err(|_| "Azure catalog validation failed; prior login preserved")?
220 .error_for_status()
221 .map_err(|_| "Azure catalog validation failed; prior login preserved")?
222 .json()
223 .await
224 .map_err(|_| "invalid Azure deployment catalog")?;
225 let deployment_exists = deployments["value"]
226 .as_array()
227 .into_iter()
228 .flatten()
229 .any(|d| d["name"].as_str() == Some(config.deployment.as_str()));
230 if !deployment_exists {
231 return Err("Azure deployment is absent from catalog; prior login preserved".into());
232 }
233 super::save_cloud_state(
234 "azure-openai",
235 &serde_json::json!({"config":config,"client_id":client_id,"endpoint":endpoint,"refresh_token":infer["refresh_token"].as_str().unwrap_or(&refresh),"arm":{"access_token":arm["access_token"],"expires_at":now+arm["expires_in"].as_u64().unwrap_or(3600)*1000},"inference":{"access_token":infer["access_token"],"expires_at":now+infer["expires_in"].as_u64().unwrap_or(3600)*1000}}),
236 )
237}
238
239async fn login_google_vertex(ui: &mut dyn CloudLoginUi) -> Result<(), String> {
240 use base64::Engine;
241 use sha2::Digest;
242 let client_id = std::env::var("SYNAPS_VERTEX_CLIENT_ID")
243 .or_else(|_| std::env::var("SYNAPS_GOOGLE_VERTEX_CLIENT_ID"))
244 .ok()
245 .filter(|v| !v.trim().is_empty())
246 .ok_or("registration_required: configure SYNAPS_VERTEX_CLIENT_ID")?;
247 let reg = super::google_vertex::VertexRegistration::new(Some(&client_id))
248 .map_err(|e| e.to_string())?;
249 let config = super::GoogleVertexConfig::new(
250 required_env("SYNAPS_VERTEX_PROJECT")?,
251 required_env("SYNAPS_VERTEX_LOCATION")?,
252 )?;
253 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
254 .await
255 .map_err(|e| e.to_string())?;
256 let port = listener.local_addr().map_err(|e| e.to_string())?.port();
257 let redirect = format!("http://127.0.0.1:{port}/oauth2callback");
258 let verifier =
259 uuid::Uuid::new_v4().simple().to_string() + &uuid::Uuid::new_v4().simple().to_string();
260 let challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD
261 .encode(sha2::Sha256::digest(verifier.as_bytes()));
262 let state = uuid::Uuid::new_v4().simple().to_string();
263 let url = super::google_vertex::build_authorize_url(®, &challenge, &state, &redirect)
264 .map_err(|e| e.to_string())?;
265 ui.present_challenge(CloudLoginChallenge::AuthorizationUrl {
266 url: url.to_string(),
267 });
268 let (mut socket, _) = listener.accept().await.map_err(|e| e.to_string())?;
269 use tokio::io::{AsyncReadExt, AsyncWriteExt};
270 let mut b = vec![0; 8192];
271 let n = socket.read(&mut b).await.map_err(|e| e.to_string())?;
272 let first = std::str::from_utf8(&b[..n])
273 .map_err(|_| "invalid OAuth callback")?
274 .lines()
275 .next()
276 .ok_or("invalid OAuth callback")?;
277 let target = first
278 .split_whitespace()
279 .nth(1)
280 .ok_or("invalid OAuth callback")?;
281 let u = url::Url::parse(&format!("http://127.0.0.1{target}"))
282 .map_err(|_| "invalid OAuth callback")?;
283 let q: std::collections::HashMap<_, _> = u.query_pairs().into_owned().collect();
284 if q.get("state") != Some(&state) {
285 return Err("OAuth state mismatch".into());
286 }
287 let code = q.get("code").ok_or("Google authorization denied")?;
288 socket
289 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 26\r\n\r\nAuthorization complete.")
290 .await
291 .ok();
292 let http = reqwest::Client::builder()
293 .redirect(reqwest::redirect::Policy::none())
294 .build()
295 .map_err(|e| e.to_string())?;
296 let v: serde_json::Value = http
297 .post(super::google_vertex::TOKEN_URL)
298 .form(&[
299 ("client_id", client_id.as_str()),
300 ("grant_type", "authorization_code"),
301 ("code", code.as_str()),
302 ("code_verifier", verifier.as_str()),
303 ("redirect_uri", redirect.as_str()),
304 ])
305 .send()
306 .await
307 .map_err(|_| "Vertex token exchange failed")?
308 .json()
309 .await
310 .map_err(|_| "invalid Vertex token response")?;
311 let refresh = v["refresh_token"]
312 .as_str()
313 .ok_or("Google did not issue offline refresh access")?;
314 let now = std::time::SystemTime::now()
315 .duration_since(std::time::UNIX_EPOCH)
316 .unwrap_or_default()
317 .as_millis() as u64;
318 let catalog_url = format!(
321 "https://{}-aiplatform.googleapis.com/v1/projects/{}/locations/{}/publishers/google/models?pageSize=1",
322 config.location, config.project_id, config.location
323 );
324 let catalog: serde_json::Value = http
325 .get(catalog_url)
326 .bearer_auth(
327 v["access_token"]
328 .as_str()
329 .ok_or("invalid Vertex token response")?,
330 )
331 .send()
332 .await
333 .map_err(|_| "Vertex catalog validation failed; prior login preserved")?
334 .error_for_status()
335 .map_err(|_| "Vertex catalog validation failed; prior login preserved")?
336 .json()
337 .await
338 .map_err(|_| "invalid Vertex model catalog")?;
339 if catalog["publisherModels"]
340 .as_array()
341 .map_or(true, |models| models.is_empty())
342 {
343 return Err("Vertex model catalog is empty; prior login preserved".into());
344 }
345 super::save_cloud_state(
346 "google-vertex",
347 &serde_json::json!({"config":config,"client_id":client_id,"access_token":v["access_token"],"refresh_token":refresh,"expires_at":now+v["expires_in"].as_u64().unwrap_or(3600)*1000}),
348 )
349}
350
351fn resolve_config(
352 configured: Option<String>,
353 variable: &'static str,
354 label: &'static str,
355 ui: &mut dyn CloudLoginUi,
356) -> Result<String, String> {
357 configured
358 .filter(|value| !value.trim().is_empty())
359 .map(Ok)
360 .unwrap_or_else(|| ui.prompt_config(variable, label))
361 .and_then(|value| {
362 let value = value.trim().to_owned();
363 if value.is_empty() {
364 Err(format!("{variable} cannot be empty"))
365 } else {
366 Ok(value)
367 }
368 })
369}
370
371fn saved_aws_config() -> Option<super::AwsBedrockConfig> {
372 super::load_cloud_state("aws-bedrock")
373 .ok()
374 .flatten()
375 .and_then(|state| serde_json::from_value(state["config"].clone()).ok())
376}
377
378async fn login_aws_bedrock(ui: &mut dyn CloudLoginUi) -> Result<(), String> {
379 use super::aws_bedrock::{AwsApi, AwsHttpApi, TokenGrant};
380 let saved = saved_aws_config();
381 let start = resolve_config(
382 std::env::var("SYNAPS_AWS_SSO_START_URL")
383 .ok()
384 .or_else(|| saved.as_ref().map(|v| v.sso_start_url.clone())),
385 "SYNAPS_AWS_SSO_START_URL",
386 "IAM Identity Center start URL",
387 ui,
388 )?;
389 let sso_region = resolve_config(
390 std::env::var("SYNAPS_AWS_SSO_REGION")
391 .ok()
392 .or_else(|| saved.as_ref().map(|v| v.sso_region.clone())),
393 "SYNAPS_AWS_SSO_REGION",
394 "IAM Identity Center region",
395 ui,
396 )?;
397 let bedrock_region = resolve_config(
398 std::env::var("SYNAPS_AWS_BEDROCK_REGION")
399 .ok()
400 .or_else(|| saved.map(|v| v.bedrock_region)),
401 "SYNAPS_AWS_BEDROCK_REGION",
402 "Bedrock region",
403 ui,
404 )?;
405 let http = reqwest::Client::builder()
406 .redirect(reqwest::redirect::Policy::none())
407 .build()
408 .map_err(|_| "cannot initialize HTTPS client")?;
409 let api = AwsHttpApi::new(http.clone(), &sso_region);
410 let client = api
411 .register_client(&sso_region)
412 .await
413 .map_err(|e| e.to_string())?;
414 let device = api
415 .start_device_authorization(&client, &start)
416 .await
417 .map_err(|e| e.to_string())?;
418 ui.present_challenge(CloudLoginChallenge::DeviceCode {
419 verification_uri: device.verification_uri.clone(),
420 user_code: device.user_code.clone(),
421 });
422 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(device.expires_in);
423 let mut interval = device.interval.max(1);
424 let token = loop {
425 if std::time::Instant::now() >= deadline {
426 return Err("device authorization expired".into());
427 }
428 tokio::time::sleep(std::time::Duration::from_secs(interval)).await;
429 match api
430 .create_token(
431 &client,
432 &sso_region,
433 TokenGrant::DeviceCode(device.device_code()),
434 )
435 .await
436 {
437 Ok(token) => break token,
438 Err(super::aws_bedrock::AwsError::AuthorizationPending) => {}
439 Err(super::aws_bedrock::AwsError::SlowDown) => interval = interval.saturating_add(5),
440 Err(e) => return Err(e.to_string()),
441 }
442 };
443 let accounts = api
444 .list_accounts(&sso_region, token.access())
445 .await
446 .map_err(|e| e.to_string())?;
447 let account = choose(
448 CloudSelectionKind::Account,
449 accounts
450 .iter()
451 .map(|a| CloudLoginChoice::new(&a.id, &a.name))
452 .collect(),
453 std::env::var("SYNAPS_AWS_ACCOUNT_ID").ok().as_deref(),
454 ui,
455 )?;
456 let roles = api
457 .list_account_roles(&sso_region, token.access(), &account)
458 .await
459 .map_err(|e| e.to_string())?;
460 let role = choose(
461 CloudSelectionKind::Role,
462 roles
463 .iter()
464 .map(|r| CloudLoginChoice::new(&r.name, ""))
465 .collect(),
466 std::env::var("SYNAPS_AWS_ROLE_NAME").ok().as_deref(),
467 ui,
468 )?;
469 let credentials = api
470 .get_role_credentials(&sso_region, token.access(), &account, &role)
471 .await
472 .map_err(|e| e.to_string())?;
473 let config = super::AwsBedrockConfig::new(start, sso_region, account, role, bedrock_region)?;
474 let validation = super::aws_bedrock::AwsBedrockBroker::from_credentials(
478 super::aws_bedrock::AwsHttpApi::new(http.clone(), &config.sso_region),
479 config.clone(),
480 super::aws_bedrock::RoleCredentials::new(
481 credentials.access_key(),
482 credentials.secret_key(),
483 credentials.session_token(),
484 credentials.expires_at,
485 ),
486 );
487 validation
488 .catalog()
489 .await
490 .map_err(|_| "AWS Bedrock catalog validation failed; prior login preserved".to_string())?;
491 let state = serde_json::json!({
492 "config": config, "access_key": credentials.access_key(), "secret_key": credentials.secret_key(),
493 "session_token": credentials.session_token(), "expires_at": credentials.expires_at,
494 "registered_client": {"id": client.id(), "secret": client.secret(), "expires_at": client.expires_at},
495 "sso_access_token": token.access(), "sso_refresh_token": token.refresh(),
496 "sso_expires_at": std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_millis() as u64 + token.expires_in * 1000
497 });
498 super::save_cloud_state("aws-bedrock", &state)
499}
500
501fn choose(
502 kind: CloudSelectionKind,
503 values: Vec<CloudLoginChoice>,
504 configured: Option<&str>,
505 ui: &mut dyn CloudLoginUi,
506) -> Result<String, String> {
507 if let Some(want) = configured {
508 return values
509 .iter()
510 .find(|v| v.id == want)
511 .map(|v| v.id.clone())
512 .ok_or_else(|| format!("configured {} is not assigned", kind.name()));
513 }
514 if values.len() == 1 {
515 return Ok(values[0].id.clone());
516 }
517 ui.select(kind, &values)
518}
519
520impl CloudSelectionKind {
521 fn name(self) -> &'static str {
522 match self {
523 Self::Account => "account",
524 Self::Role => "role",
525 }
526 }
527}
528
529#[cfg(test)]
530mod ui_tests {
531 use super::*;
532
533 #[derive(Default)]
534 struct RecordingUi {
535 challenges: Vec<CloudLoginChallenge>,
536 selections: Vec<(CloudSelectionKind, Vec<CloudLoginChoice>)>,
537 }
538
539 impl CloudLoginUi for RecordingUi {
540 fn present_challenge(&mut self, challenge: CloudLoginChallenge) {
541 self.challenges.push(challenge);
542 }
543
544 fn prompt_config(
545 &mut self,
546 _variable: &'static str,
547 _label: &'static str,
548 ) -> Result<String, String> {
549 Err("unexpected prompt".into())
550 }
551
552 fn select(
553 &mut self,
554 kind: CloudSelectionKind,
555 choices: &[CloudLoginChoice],
556 ) -> Result<String, String> {
557 self.selections.push((kind, choices.to_vec()));
558 Ok(choices[1].id.clone())
559 }
560 }
561
562 #[test]
563 fn aws_context_uses_configured_values_without_prompting() {
564 let mut ui = RecordingUi::default();
565 assert_eq!(
566 resolve_config(
567 Some("us-east-1".into()),
568 "SYNAPS_AWS_SSO_REGION",
569 "SSO region",
570 &mut ui
571 )
572 .unwrap(),
573 "us-east-1"
574 );
575 }
576
577 struct ConfigUi {
578 prompts: Vec<(&'static str, &'static str)>,
579 answer: Result<String, String>,
580 }
581 impl CloudLoginUi for ConfigUi {
582 fn present_challenge(&mut self, _: CloudLoginChallenge) {}
583 fn prompt_config(
584 &mut self,
585 variable: &'static str,
586 label: &'static str,
587 ) -> Result<String, String> {
588 self.prompts.push((variable, label));
589 self.answer.clone()
590 }
591 fn select(
592 &mut self,
593 _: CloudSelectionKind,
594 _: &[CloudLoginChoice],
595 ) -> Result<String, String> {
596 Err("unexpected selection".into())
597 }
598 }
599
600 #[test]
601 fn aws_context_prompts_for_missing_value_through_ui() {
602 let mut ui = ConfigUi {
603 prompts: vec![],
604 answer: Ok("us-west-2".into()),
605 };
606 assert_eq!(
607 resolve_config(None, "SYNAPS_AWS_BEDROCK_REGION", "Bedrock region", &mut ui).unwrap(),
608 "us-west-2"
609 );
610 assert_eq!(
611 ui.prompts,
612 vec![("SYNAPS_AWS_BEDROCK_REGION", "Bedrock region")]
613 );
614 }
615
616 #[test]
617 fn aws_context_reports_noninteractive_missing_variable() {
618 let mut ui = ConfigUi {
619 prompts: vec![],
620 answer: Err(
621 "non-interactive login requires SYNAPS_AWS_SSO_START_URL or saved config".into(),
622 ),
623 };
624 let error =
625 resolve_config(None, "SYNAPS_AWS_SSO_START_URL", "SSO start URL", &mut ui).unwrap_err();
626 assert!(error.contains("SYNAPS_AWS_SSO_START_URL"));
627 }
628
629 #[test]
630 fn injected_ui_receives_typed_challenges_and_selections() {
631 let mut ui = RecordingUi::default();
632 ui.present_challenge(CloudLoginChallenge::DeviceCode {
633 verification_uri: "https://example.test".into(),
634 user_code: "ABCD".into(),
635 });
636 let selected = choose(
637 CloudSelectionKind::Account,
638 vec![
639 CloudLoginChoice::new("1", "first"),
640 CloudLoginChoice::new("2", "second"),
641 ],
642 None,
643 &mut ui,
644 )
645 .unwrap();
646 assert_eq!(selected, "2");
647 assert_eq!(ui.challenges.len(), 1);
648 assert_eq!(ui.selections[0].0, CloudSelectionKind::Account);
649 }
650}