wasmer_cli/commands/auth/login/
mod.rs1mod 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#[derive(Debug, Clone, clap::Parser)]
25pub struct Login {
26 #[clap(long, name = "no-browser", default_value = "false")]
28 pub no_browser: bool,
29
30 #[clap(long, env = "WASMER_DIR", default_value = crate::config::DEFAULT_WASMER_DIR.as_os_str())]
34 pub wasmer_dir: PathBuf,
35
36 #[clap(long, env = "WASMER_CACHE_DIR", default_value = crate::config::DEFAULT_WASMER_CACHE_DIR.as_os_str())]
38 pub cache_dir: PathBuf,
39
40 #[clap(env = "WASMER_TOKEN")]
42 pub token: Option<String>,
43
44 #[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 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 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 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 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 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 break;
147 }
148 }
149 }
150
151 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 _ = 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 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 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 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 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 #[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 assert!(res.is_ok());
403 }
404}