Skip to main content

agent_core/core/auth/
cloud_login.rs

1//! Provider-specific interactive cloud login protocols.
2//! Credentials remain persisted only through the broker-owned auth store.
3
4use 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
37/// Presentation boundary for interactive cloud login. Core never accesses a terminal.
38pub trait CloudLoginUi {
39    fn present_challenge(&mut self, challenge: CloudLoginChallenge);
40    /// Request non-secret provider configuration. Implementations must return a
41    /// clear error instead of blocking when no interactive input is available.
42    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
54/// Whether this build has an explicit login implementation for a cloud provider.
55pub const fn supports_login(provider: CloudProviderId) -> bool {
56    match provider {
57        CloudProviderId::AwsBedrock
58        | CloudProviderId::AzureOpenAi
59        | CloudProviderId::GoogleVertex => true,
60    }
61}
62
63/// Execute the provider-specific protocol selected by typed identity.
64pub 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, &reg).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, &reg, 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    // Resolve the data-plane endpoint from ARM metadata before committing any
178    // state; never derive an authority from the user-supplied resource name.
179    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    // Validate the selected resource/deployment against ARM before replacing a
205    // prior login. This is deliberately before the sole persistence point.
206    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(&reg, &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    // Validate access and context before the only commit, preserving old state
319    // on OAuth success followed by an unusable Vertex catalog.
320    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    // The login transaction is not committed until the selected Bedrock
475    // context can return a non-empty catalog. A failed relogin must leave the
476    // previously working credential untouched.
477    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}