wasmer_cli/commands/auth/login/
mod.rs

1mod auth_server;
2use auth_server::*;
3use colored::Colorize;
4use hyper::{server::conn::http1::Builder, service::service_fn};
5use hyper_util::server::graceful::GracefulShutdown;
6
7use crate::{
8    commands::AsyncCliCommand,
9    config::{UpdateRegistry, UserRegistry, WasmerConfig, WasmerEnv},
10};
11use futures_util::{StreamExt, stream::FuturesUnordered};
12use std::{path::PathBuf, time::Duration};
13use wasmer_backend_api::{WasmerClient, types::Nonce};
14
15#[derive(Debug, Clone)]
16enum AuthorizationState {
17    TokenSuccess(String),
18    Cancelled,
19    TimedOut,
20    UnknownMethod,
21}
22
23/// Login into Wasmer (using a browser or by providing a token created in https://wasmer.io/settings/access-tokens)
24#[derive(Debug, Clone, clap::Parser)]
25pub struct Login {
26    /// Variable to login without opening a browser
27    #[clap(long, name = "no-browser", default_value = "false")]
28    pub no_browser: bool,
29
30    // This is a copy of [`WasmerEnv`] to allow users to specify
31    // the token as a parameter rather than as a flag.
32    /// Set Wasmer's home directory
33    #[clap(long, env = "WASMER_DIR", default_value = crate::config::DEFAULT_WASMER_DIR.as_os_str())]
34    pub wasmer_dir: PathBuf,
35
36    /// The directory cached artefacts are saved to.
37    #[clap(long, env = "WASMER_CACHE_DIR", default_value = crate::config::DEFAULT_WASMER_CACHE_DIR.as_os_str())]
38    pub cache_dir: PathBuf,
39
40    /// The API token to use when communicating with the registry (inferred from the environment by default)
41    #[clap(env = "WASMER_TOKEN")]
42    pub token: Option<String>,
43
44    /// Change the current registry
45    #[clap(long, env = "WASMER_REGISTRY")]
46    pub registry: Option<UserRegistry>,
47}
48
49impl Login {
50    fn get_token_from_env_or_user(
51        &self,
52        env: &WasmerEnv,
53    ) -> Result<AuthorizationState, anyhow::Error> {
54        if let Some(token) = &self.token {
55            return Ok(AuthorizationState::TokenSuccess(token.clone()));
56        }
57
58        let public_url = env.registry_public_url()?;
59
60        let login_prompt = match public_url.domain() {
61            Some(d) => {
62                format!("Please paste the login token from https://{d}/settings/access-tokens")
63            }
64            _ => "Please paste the login token".to_string(),
65        };
66
67        #[cfg(test)]
68        {
69            Ok(AuthorizationState::TokenSuccess(login_prompt))
70        }
71        #[cfg(not(test))]
72        {
73            let token = dialoguer::Input::new()
74                .with_prompt(&login_prompt)
75                .interact_text()?;
76            Ok(AuthorizationState::TokenSuccess(token))
77        }
78    }
79
80    async fn get_token_from_browser(
81        &self,
82        client: &WasmerClient,
83    ) -> anyhow::Result<AuthorizationState> {
84        let (listener, server_url) = setup_listener().await?;
85
86        let (server_shutdown_tx, mut server_shutdown_rx) = tokio::sync::mpsc::channel::<bool>(1);
87        let (token_tx, mut token_rx) = tokio::sync::mpsc::channel::<AuthorizationState>(1);
88
89        // Create a new AppContext
90        let app_context = BrowserAuthContext {
91            server_shutdown_tx,
92            token_tx,
93        };
94
95        let Nonce { auth_url, .. } =
96            wasmer_backend_api::query::create_nonce(client, "wasmer-cli".to_string(), server_url)
97                .await?
98                .ok_or_else(|| {
99                    anyhow::anyhow!("The backend did not return any nonce to auth the login!")
100                })?;
101
102        // if failed to open the browser, then don't error out just print the auth_url with a message
103        println!("Opening auth link in your default browser: {}", &auth_url);
104        println!(
105            "{}: If browser driven login does not work, manually create a token at https://wasmer.io/settings/access-tokens and log in with `wasmer login <TOKEN>`",
106            "NOTE".yellow().bold()
107        );
108        opener::open_browser(&auth_url).unwrap_or_else(|_| {
109            println!(
110                "⚠️ Failed to open the browser.\n
111            Please open the url: {}",
112                &auth_url
113            );
114        });
115
116        // Jump through hyper 1.0's hoops...
117        let graceful = GracefulShutdown::new();
118
119        let http = Builder::new();
120
121        let mut futs = FuturesUnordered::new();
122
123        let service = service_fn(move |req| service_router(app_context.clone(), req));
124
125        print!("Waiting for session... ");
126
127        // start the server
128        loop {
129            tokio::select! {
130                Result::Ok((stream, _addr)) = listener.accept() => {
131                    let io = hyper_util::rt::tokio::TokioIo::new(stream);
132                    let conn = http.serve_connection(io, service.clone());
133                    // watch this connection
134                    let fut = graceful.watch(conn);
135                    futs.push(async move {
136                        if let Err(e) = fut.await {
137                            eprintln!("Error serving connection: {e:?}");
138                        }
139                    });
140                },
141
142                _ = futs.next() => {}
143
144                _ = server_shutdown_rx.recv() => {
145                    // stop the accept loop
146                    break;
147                }
148            }
149        }
150
151        // receive the token from the server
152        let token = token_rx
153            .recv()
154            .await
155            .ok_or_else(|| anyhow::anyhow!("❌ Failed to receive token from localhost"))?;
156
157        Ok(token)
158    }
159
160    async fn do_login(&self, env: &WasmerEnv) -> anyhow::Result<AuthorizationState> {
161        let client = env.client_unauthennticated()?;
162
163        let should_login =
164            if let Some(user) = wasmer_backend_api::query::current_user(&client).await? {
165                #[cfg(not(test))]
166                {
167                    println!(
168                        "You are already logged in as {} in registry {}.",
169                        user.username.bold(),
170                        env.registry_public_url()?.host_str().unwrap().bold()
171                    );
172                    let theme = dialoguer::theme::ColorfulTheme::default();
173                    let dialog = dialoguer::Confirm::with_theme(&theme).with_prompt("Login again?");
174
175                    dialog.interact()?
176                }
177                #[cfg(test)]
178                {
179                    // prevent unused binding warning
180                    _ = user;
181
182                    false
183                }
184            } else {
185                true
186            };
187
188        if !should_login {
189            Ok(AuthorizationState::Cancelled)
190        } else if self.no_browser {
191            self.get_token_from_env_or_user(env)
192        } else {
193            // switch between two methods of getting the token.
194            // start two async processes, 10 minute timeout and get token from browser. Whichever finishes first, use that.
195            let timeout_future = tokio::time::sleep(Duration::from_secs(60 * 10));
196            tokio::select! {
197             _ = timeout_future => {
198                     Ok(AuthorizationState::TimedOut)
199                 },
200                 token = self.get_token_from_browser(&client) => {
201                    token
202                 }
203            }
204        }
205    }
206
207    async fn login_and_save(&self, env: &WasmerEnv, token: String) -> anyhow::Result<String> {
208        let registry = env.registry_endpoint()?;
209        let mut config = WasmerConfig::from_file(env.dir())
210            .map_err(|e| anyhow::anyhow!("config from file: {e}"))?;
211        config
212            .registry
213            .set_current_registry(registry.as_ref())
214            .await;
215        config.registry.set_login_token_for_registry(
216            &config.registry.get_current_registry(),
217            &token,
218            UpdateRegistry::Update,
219        );
220        let path = WasmerConfig::get_file_location(env.dir());
221        config.save(path)?;
222
223        // This will automatically read the config again, picking up the new edits.
224        let client = env.client()?;
225
226        wasmer_backend_api::query::current_user(&client)
227            .await?
228            .map(|v| v.username)
229            .ok_or_else(|| anyhow::anyhow!("Not logged in!"))
230    }
231
232    pub(crate) fn get_wasmer_env(&self) -> WasmerEnv {
233        WasmerEnv::new(
234            self.wasmer_dir.clone(),
235            self.cache_dir.clone(),
236            self.token.clone(),
237            self.registry.clone(),
238        )
239    }
240}
241
242#[async_trait::async_trait]
243impl AsyncCliCommand for Login {
244    type Output = ();
245
246    async fn run_async(self) -> Result<Self::Output, anyhow::Error> {
247        let env = self.get_wasmer_env();
248
249        let auth_state = match &self.token {
250            Some(token) => AuthorizationState::TokenSuccess(token.clone()),
251            None => self.do_login(&env).await?,
252        };
253
254        match auth_state {
255            AuthorizationState::TokenSuccess(token) => {
256                match self.login_and_save(&env, token).await {
257                    Ok(s) => {
258                        print!("Done!");
259                        println!(
260                            "\n{} Login for Wasmer user {:?} saved",
261                            "✔".green().bold(),
262                            s
263                        )
264                    }
265                    Err(_) => print!(
266                        "Warning: no user found on {:?} with the provided token.\nToken saved regardless.",
267                        env.registry_public_url()
268                    ),
269                }
270            }
271            AuthorizationState::TimedOut => {
272                print!("Timed out (10 mins exceeded)");
273            }
274            AuthorizationState::Cancelled => {
275                println!("Cancelled by the user");
276            }
277            AuthorizationState::UnknownMethod => {
278                println!("Error: unknown method\n");
279            }
280        };
281
282        Ok(())
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use clap::CommandFactory;
289    use tempfile::TempDir;
290
291    use crate::commands::CliCommand;
292
293    use super::*;
294
295    #[test]
296    fn interactive_login() {
297        let temp = TempDir::new().unwrap();
298        let login = Login {
299            no_browser: true,
300            registry: Some("wasmer.wtf".into()),
301            wasmer_dir: temp.path().to_path_buf(),
302            token: None,
303            cache_dir: temp.path().join("cache").to_path_buf(),
304        };
305        let env = login.get_wasmer_env();
306
307        let token = login.get_token_from_env_or_user(&env).unwrap();
308        match token {
309            AuthorizationState::TokenSuccess(token) => {
310                assert_eq!(
311                    token,
312                    "Please paste the login token from https://wasmer.wtf/settings/access-tokens"
313                );
314            }
315            AuthorizationState::Cancelled
316            | AuthorizationState::TimedOut
317            | AuthorizationState::UnknownMethod => {
318                panic!("Should not reach here")
319            }
320        }
321    }
322
323    #[test]
324    fn login_with_token() {
325        let temp = TempDir::new().unwrap();
326        let login = Login {
327            no_browser: true,
328            registry: Some("wasmer.wtf".into()),
329            wasmer_dir: temp.path().to_path_buf(),
330            token: Some("abc".to_string()),
331            cache_dir: temp.path().join("cache").to_path_buf(),
332        };
333        let env = login.get_wasmer_env();
334
335        let token = login.get_token_from_env_or_user(&env).unwrap();
336
337        match token {
338            AuthorizationState::TokenSuccess(token) => {
339                assert_eq!(token, "abc");
340            }
341            AuthorizationState::Cancelled
342            | AuthorizationState::TimedOut
343            | AuthorizationState::UnknownMethod => {
344                panic!("Should not reach here")
345            }
346        }
347    }
348
349    #[test]
350    fn in_sync_with_wasmer_env() {
351        let wasmer_env = WasmerEnv::command();
352        let login = Login::command();
353
354        // All options except --token should be the same
355        let wasmer_env_opts: Vec<_> = wasmer_env
356            .get_opts()
357            .filter(|arg| arg.get_id() != "token")
358            .collect();
359        let login_opts: Vec<_> = login.get_opts().collect();
360
361        assert_eq!(wasmer_env_opts, login_opts);
362
363        // The token argument should have the same message, even if it is an
364        // argument rather than a --flag.
365        let wasmer_env_token_help = wasmer_env
366            .get_opts()
367            .find(|arg| arg.get_id() == "token")
368            .unwrap()
369            .get_help()
370            .unwrap()
371            .to_string();
372        let login_token_help = login
373            .get_positionals()
374            .find(|arg| arg.get_id() == "token")
375            .unwrap()
376            .get_help()
377            .unwrap()
378            .to_string();
379        assert_eq!(wasmer_env_token_help, login_token_help);
380    }
381
382    /// Regression test for panics on API errors.
383    /// See https://github.com/wasmerio/wasmer/issues/4147.
384    #[test]
385    fn login_with_invalid_token_does_not_panic() {
386        let cmd = Login {
387            no_browser: true,
388            wasmer_dir: crate::config::DEFAULT_WASMER_DIR.clone(),
389            registry: Some("http://localhost:11".to_string().into()),
390            token: Some("invalid".to_string()),
391            cache_dir: crate::config::DEFAULT_WASMER_CACHE_DIR.clone(),
392        };
393
394        let res = cmd.run();
395        // The CLI notices that either the registry is unreachable or the token is not tied to any
396        // user. It shows a warning to the user, but does not return with an error code.
397        //
398        //  ------ i.e. this will fail
399        // |
400        // v
401        // assert!(res.is_err());
402        assert!(res.is_ok());
403    }
404}