diff --git a/crates/cli/src/subcommands/init.rs b/crates/cli/src/subcommands/init.rs index 58d567ef85d..8d1041d64c3 100644 --- a/crates/cli/src/subcommands/init.rs +++ b/crates/cli/src/subcommands/init.rs @@ -18,7 +18,7 @@ use toml_edit::{value, DocumentMut, Item}; use xmltree::{Element, XMLNode}; use crate::spacetime_config::{PackageManager, SpacetimeConfig, CONFIG_FILENAME}; -use crate::subcommands::login::{spacetimedb_login_force, DEFAULT_AUTH_HOST}; +use crate::subcommands::login::{spacetimedb_login_and_save, DEFAULT_AUTH_HOST}; mod embedded { use spacetimedb_data_structures::map::HashCollectionExt as _; @@ -218,7 +218,7 @@ pub async fn check_and_prompt_login(config: &mut Config) -> anyhow::Result if should_login { let host = Url::parse(DEFAULT_AUTH_HOST)?; - spacetimedb_login_force(config, &host, false, true).await?; + spacetimedb_login_and_save(config, &host, false, true).await?; println!("{}", "Successfully logged in!".green()); Ok(true) } else { diff --git a/crates/cli/src/subcommands/login.rs b/crates/cli/src/subcommands/login.rs index ef696d06a81..df07ec5087d 100644 --- a/crates/cli/src/subcommands/login.rs +++ b/crates/cli/src/subcommands/login.rs @@ -1,5 +1,5 @@ -use crate::util::decode_identity; use crate::Config; +use crate::{logout::ensure_logged_out, util::decode_identity}; use clap::{Arg, ArgAction, ArgGroup, ArgMatches, Command}; use reqwest::Url; use serde::Deserialize; @@ -62,17 +62,22 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E let server_issued_login: Option<&String> = args.get_one("server"); let open_browser = !args.get_flag("no-browser"); + let _was_logged_in = ensure_logged_out(&mut config, &host).await; + if let Some(token) = spacetimedb_token { config.set_spacetimedb_token(token.clone()); config.save(); - return Ok(()); + match decode_identity(token) { + Ok(identity) => println!("Logged in with identity {identity}"), + Err(_) => println!("Token saved."), + } } if let Some(server) = server_issued_login { let host = Url::parse(&config.get_host_url(Some(server))?)?; - spacetimedb_token_cached(&mut config, &host, true, open_browser).await?; + spacetimedb_login_and_save(&mut config, &host, true, open_browser).await?; } else { - spacetimedb_token_cached(&mut config, &host, false, open_browser).await?; + spacetimedb_login_and_save(&mut config, &host, false, open_browser).await?; } Ok(()) @@ -105,24 +110,7 @@ async fn exec_show(config: Config, args: &ArgMatches) -> Result<(), anyhow::Erro Ok(()) } -async fn spacetimedb_token_cached( - config: &mut Config, - host: &Url, - direct_login: bool, - open_browser: bool, -) -> anyhow::Result { - // Currently, this token does not expire. However, it will at some point in the future. When that happens, - // this code will need to happen before any request to a spacetimedb server, rather than at the end of the login flow here. - if let Some(token) = config.spacetimedb_token() { - println!("You are already logged in."); - println!("If you want to log out, use spacetime logout."); - Ok(token.clone()) - } else { - spacetimedb_login_force(config, host, direct_login, open_browser).await - } -} - -pub async fn spacetimedb_login_force( +pub async fn spacetimedb_login_and_save( config: &mut Config, host: &Url, direct_login: bool, @@ -134,16 +122,19 @@ pub async fn spacetimedb_login_force( println!("WARNING: This login will NOT work for any other servers."); token } else { - let session_token = web_login_cached(config, host, open_browser).await?; + let session_token = web_login_or_cached(config, host, open_browser).await?; spacetimedb_login(host, &session_token).await? }; config.set_spacetimedb_token(token.clone()); config.save(); + let identity = decode_identity(&token)?; + println!("Logged in with identity {identity}"); + Ok(token) } -async fn web_login_cached(config: &mut Config, host: &Url, open_browser: bool) -> anyhow::Result { +async fn web_login_or_cached(config: &mut Config, host: &Url, open_browser: bool) -> anyhow::Result { if let Some(session_token) = config.web_session_token() { // Currently, these session tokens do not expire. At some point in the future, we may also need to check this session token for validity. Ok(session_token.clone()) diff --git a/crates/cli/src/subcommands/logout.rs b/crates/cli/src/subcommands/logout.rs index 20cc617e1c6..18c3ef07e48 100644 --- a/crates/cli/src/subcommands/logout.rs +++ b/crates/cli/src/subcommands/logout.rs @@ -1,8 +1,8 @@ -use std::time::Duration; - +use crate::util::decode_identity; use crate::Config; use clap::{Arg, ArgMatches, Command}; use reqwest::Url; +use std::time::Duration; pub fn cli() -> Command { Command::new("logout").arg( @@ -14,27 +14,55 @@ pub fn cli() -> Command { } pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { + // Check if already logged out. + if config.spacetimedb_token().is_none() && config.web_session_token().is_none() { + println!("You are not logged in."); + return Ok(()); + } + let host: &String = args.get_one("auth-host").unwrap(); let host = Url::parse(host)?; - if let Some(web_session_token) = config.web_session_token() { - let client = reqwest::Client::builder().timeout(Duration::from_secs(5)).build()?; - let result = client - .post(host.join("auth/cli/logout")?) - .header("Authorization", format!("Bearer {web_session_token}")) - .send() - .await; - - if let Err(e) = result { - eprintln!( - "Warning: Could not reach auth server to invalidate session: {e}\n\ - Local credentials have been cleared." - ); - } - } + let _ = ensure_logged_out(&mut config, &host).await; + + Ok(()) +} +async fn server_logout(config: &mut Config, host: &Url) -> Result<(), anyhow::Error> { + let Some(web_session_token) = config.web_session_token() else { + anyhow::bail!("No web session token"); + }; + // Best-effort server-side session invalidation. + let client = reqwest::Client::builder().timeout(Duration::from_secs(5)).build()?; + client + .post(host.join("auth/cli/logout")?) + .header("Authorization", format!("Bearer {web_session_token}")) + .send() + .await?; + Ok(()) +} + +/// Logs out the user from the specified auth server. +/// Returns true if the user was logged out, false if they were not logged in. +pub async fn ensure_logged_out(config: &mut Config, host: &Url) -> bool { + let Some(token) = config.spacetimedb_token() else { + return false; + }; + // Grab identity before clearing tokens. + let identity = decode_identity(token).ok(); + + // Best-effort server-side session invalidation. + if let Err(e) = server_logout(config, host).await { + eprintln!("Warning: Failed to logout from auth server: {e}\nLocal credentials have been cleared."); + } config.clear_login_tokens(); config.save(); - Ok(()) + if let Some(id) = identity { + println!("Logged out (identity {id})."); + } else { + println!("Logged out."); + } + + true } diff --git a/crates/cli/src/util.rs b/crates/cli/src/util.rs index 492068cba99..ea9b15c12da 100644 --- a/crates/cli/src/util.rs +++ b/crates/cli/src/util.rs @@ -8,7 +8,7 @@ use std::io::Write; use std::path::{Path, PathBuf}; use crate::config::Config; -use crate::login::{spacetimedb_login_force, DEFAULT_AUTH_HOST}; +use crate::login::{spacetimedb_login_and_save, DEFAULT_AUTH_HOST}; pub const UNSTABLE_WARNING: &str = "WARNING: This command is UNSTABLE and subject to breaking changes."; @@ -357,10 +357,10 @@ pub async fn get_login_token_or_log_in( if full_login { let host = Url::parse(DEFAULT_AUTH_HOST)?; - spacetimedb_login_force(config, &host, false, true).await + spacetimedb_login_and_save(config, &host, false, true).await } else { let host = Url::parse(&config.get_host_url(target_server)?)?; - spacetimedb_login_force(config, &host, true, true).await + spacetimedb_login_and_save(config, &host, true, true).await } } diff --git a/crates/smoketests/tests/smoketests/cli/auth.rs b/crates/smoketests/tests/smoketests/cli/auth.rs new file mode 100644 index 00000000000..977c7e88109 --- /dev/null +++ b/crates/smoketests/tests/smoketests/cli/auth.rs @@ -0,0 +1,172 @@ +//! CLI auth command tests (`login` / `logout`) + +use spacetimedb_smoketests::{require_local_server, Smoketest}; +use std::fs; +use std::process::Output; + +fn output_stdout(output: &Output) -> String { + String::from_utf8_lossy(&output.stdout).to_string() +} + +fn output_stderr(output: &Output) -> String { + String::from_utf8_lossy(&output.stderr).to_string() +} + +fn assert_success(output: &Output, context: &str) { + assert!( + output.status.success(), + "{context} failed:\nstdout: {}\nstderr: {}", + output_stdout(output), + output_stderr(output), + ); +} + +fn read_config(test: &Smoketest) -> toml::Table { + let raw = fs::read_to_string(&test.config_path).expect("Failed to read config"); + raw.parse::().expect("Failed to parse config") +} + +fn write_config(test: &Smoketest, config: &toml::Table) { + let raw = toml::to_string(config).expect("Failed to serialize config"); + fs::write(&test.config_path, raw).expect("Failed to write config"); +} + +#[test] +fn cli_logout_removes_cached_tokens() { + require_local_server!(); + let test = Smoketest::builder().autopublish(false).build(); + + let login = test.spacetime_cmd(&["login", "--server-issued-login", &test.server_url]); + assert_success(&login, "initial login"); + + // Simulate a cached web session token; logout should clear both token fields. + let mut config = read_config(&test); + config.insert( + "web_session_token".to_string(), + toml::Value::String("fake-web-session-token".to_string()), + ); + write_config(&test, &config); + + let logout = test.spacetime_cmd(&["logout"]); + assert_success(&logout, "logout"); + assert!( + output_stdout(&logout).contains("Logged out (identity "), + "logout stdout should include identity message:\n{}", + output_stdout(&logout), + ); + + let config_after = read_config(&test); + assert!( + config_after.get("spacetimedb_token").is_none(), + "spacetimedb_token should be removed after logout: {:?}", + config_after.get("spacetimedb_token") + ); + assert!( + config_after.get("web_session_token").is_none(), + "web_session_token should be removed after logout: {:?}", + config_after.get("web_session_token") + ); +} + +#[test] +// Even if there's no web session, logout still removes the SpacetimeDB token +fn cli_logout_removes_cached_tokens_without_web_token() { + require_local_server!(); + let test = Smoketest::builder().autopublish(false).build(); + + let login = test.spacetime_cmd(&["login", "--server-issued-login", &test.server_url]); + assert_success(&login, "initial login"); + + let logout = test.spacetime_cmd(&["logout"]); + assert_success(&logout, "logout"); + assert!( + output_stdout(&logout).contains("Logged out (identity "), + "logout stdout should include identity message:\n{}", + output_stdout(&logout), + ); + + let config_after = read_config(&test); + assert!( + config_after.get("spacetimedb_token").is_none(), + "spacetimedb_token should be removed after logout: {:?}", + config_after.get("spacetimedb_token") + ); + assert!( + config_after.get("web_session_token").is_none(), + "web_session_token should be removed after logout: {:?}", + config_after.get("web_session_token") + ); +} + +#[test] +fn cli_logout_is_idempotent() { + require_local_server!(); + let test = Smoketest::builder().autopublish(false).build(); + + let login = test.spacetime_cmd(&["login", "--server-issued-login", &test.server_url]); + assert_success(&login, "initial login"); + + let first_logout = test.spacetime_cmd(&["logout"]); + assert_success(&first_logout, "first logout"); + assert!( + output_stdout(&first_logout).contains("Logged out "), + "first logout should report logged-out:\n{}", + output_stdout(&first_logout) + ); + + let second_logout = test.spacetime_cmd(&["logout"]); + assert_success(&second_logout, "second logout"); + assert!( + output_stdout(&second_logout).contains("You are not logged in."), + "second logout should report not logged in:\n{}", + output_stdout(&second_logout) + ); +} + +#[test] +fn cli_direct_login_works_and_shows_core_messages() { + require_local_server!(); + let test = Smoketest::builder().autopublish(false).build(); + + let login = test.spacetime_cmd(&["login", "--server-issued-login", &test.server_url]); + assert_success(&login, "direct login"); + + let login_stdout = output_stdout(&login); + assert!( + login_stdout.contains("Logged in "), + "direct login stdout missing confirmation:\n{}", + login_stdout + ); + + let show = test.spacetime_cmd(&["login", "show"]); + assert_success(&show, "login show"); + assert!( + output_stdout(&show).contains("You are logged in as "), + "login show should report current identity:\n{}", + output_stdout(&show) + ); +} + +#[test] +fn cli_logging_in_twice_works() { + require_local_server!(); + let test = Smoketest::builder().autopublish(false).build(); + + let first = test.spacetime_cmd(&["login", "--server-issued-login", &test.server_url]); + assert_success(&first, "first login"); + + let second = test.spacetime_cmd(&["login", "--server-issued-login", &test.server_url]); + assert_success(&second, "second login"); + + let second_stdout = output_stdout(&second); + assert!( + second_stdout.contains("Logged out (identity "), + "second login should log out previous identity first:\n{}", + second_stdout + ); + assert!( + second_stdout.contains("Logged in with identity "), + "second login should complete with a new login:\n{}", + second_stdout + ); +} diff --git a/crates/smoketests/tests/smoketests/cli/mod.rs b/crates/smoketests/tests/smoketests/cli/mod.rs index f9990ad2d38..d54c9749851 100644 --- a/crates/smoketests/tests/smoketests/cli/mod.rs +++ b/crates/smoketests/tests/smoketests/cli/mod.rs @@ -1,3 +1,4 @@ +pub mod auth; pub mod dev; pub mod generate; pub mod publish;