spacetimedb_cli/subcommands/
login.rs

1use crate::util::decode_identity;
2use crate::Config;
3use clap::{Arg, ArgAction, ArgGroup, ArgMatches, Command};
4use reqwest::Url;
5use serde::Deserialize;
6use webbrowser;
7
8pub const DEFAULT_AUTH_HOST: &str = "https://spacetimedb.com";
9
10pub fn cli() -> Command {
11    Command::new("login")
12        .args_conflicts_with_subcommands(true)
13        .subcommands(get_subcommands())
14        .group(ArgGroup::new("login-method").required(false))
15        .arg(
16            Arg::new("auth-host")
17                .long("auth-host")
18                .default_value(DEFAULT_AUTH_HOST)
19                .group("login-method")
20                .help("Fetch login token from a different host"),
21        )
22        .arg(
23            Arg::new("server")
24                .long("server-issued-login")
25                .group("login-method")
26                .help("Log in to a SpacetimeDB server directly, without going through a global auth server"),
27        )
28        .arg(
29            Arg::new("spacetimedb-token")
30                .long("token")
31                .group("login-method")
32                .help("Bypass the login flow and use a login token directly"),
33        )
34        .about("Manage your login to the SpacetimeDB CLI")
35}
36
37fn get_subcommands() -> Vec<Command> {
38    vec![Command::new("show")
39        .arg(
40            Arg::new("token")
41                .long("token")
42                .action(ArgAction::SetTrue)
43                .help("Also show the auth token"),
44        )
45        .about("Show the current login info")]
46}
47
48pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> {
49    if let Some((cmd, subcommand_args)) = args.subcommand() {
50        return exec_subcommand(config, cmd, subcommand_args).await;
51    }
52
53    let spacetimedb_token: Option<&String> = args.get_one("spacetimedb-token");
54    let host: &String = args.get_one("auth-host").unwrap();
55    let host = Url::parse(host)?;
56    let server_issued_login: Option<&String> = args.get_one("server");
57
58    if let Some(token) = spacetimedb_token {
59        config.set_spacetimedb_token(token.clone());
60        config.save();
61        return Ok(());
62    }
63
64    if let Some(server) = server_issued_login {
65        let host = Url::parse(&config.get_host_url(Some(server))?)?;
66        spacetimedb_token_cached(&mut config, &host, true).await?;
67    } else {
68        spacetimedb_token_cached(&mut config, &host, false).await?;
69    }
70
71    Ok(())
72}
73
74async fn exec_subcommand(config: Config, cmd: &str, args: &ArgMatches) -> Result<(), anyhow::Error> {
75    match cmd {
76        "show" => exec_show(config, args).await,
77        unknown => Err(anyhow::anyhow!("Invalid subcommand: {}", unknown)),
78    }
79}
80
81async fn exec_show(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> {
82    let include_token = args.get_flag("token");
83
84    let token = if let Some(token) = config.spacetimedb_token() {
85        token
86    } else {
87        println!("You are not logged in. Run `spacetime login` to log in.");
88        return Ok(());
89    };
90
91    let identity = decode_identity(token)?;
92    println!("You are logged in as {identity}");
93
94    if include_token {
95        println!("Your auth token (don't share this!) is {token}");
96    }
97
98    Ok(())
99}
100
101async fn spacetimedb_token_cached(config: &mut Config, host: &Url, direct_login: bool) -> anyhow::Result<String> {
102    // Currently, this token does not expire. However, it will at some point in the future. When that happens,
103    // this code will need to happen before any request to a spacetimedb server, rather than at the end of the login flow here.
104    if let Some(token) = config.spacetimedb_token() {
105        println!("You are already logged in.");
106        println!("If you want to log out, use spacetime logout.");
107        Ok(token.clone())
108    } else {
109        spacetimedb_login_force(config, host, direct_login).await
110    }
111}
112
113pub async fn spacetimedb_login_force(config: &mut Config, host: &Url, direct_login: bool) -> anyhow::Result<String> {
114    let token = if direct_login {
115        let token = spacetimedb_direct_login(host).await?;
116        println!("We have logged in directly to your target server.");
117        println!("WARNING: This login will NOT work for any other servers.");
118        token
119    } else {
120        let session_token = web_login_cached(config, host).await?;
121        spacetimedb_login(host, &session_token).await?
122    };
123    config.set_spacetimedb_token(token.clone());
124    config.save();
125
126    Ok(token)
127}
128
129async fn web_login_cached(config: &mut Config, host: &Url) -> anyhow::Result<String> {
130    if let Some(session_token) = config.web_session_token() {
131        // Currently, these session tokens do not expire. At some point in the future, we may also need to check this session token for validity.
132        Ok(session_token.clone())
133    } else {
134        let session_token = web_login(host).await?;
135        config.set_web_session_token(session_token.clone());
136        config.save();
137        Ok(session_token)
138    }
139}
140
141#[derive(Clone, Deserialize)]
142struct WebLoginTokenData {
143    token: String,
144}
145
146#[derive(Clone, Deserialize)]
147struct WebLoginTokenResponse {
148    success: bool,
149    data: WebLoginTokenData,
150}
151
152#[derive(Clone, Deserialize)]
153struct WebLoginSessionResponse {
154    success: bool,
155    error: Option<String>,
156    data: Option<WebLoginSessionData>,
157}
158
159#[derive(Clone, Deserialize)]
160struct WebLoginSessionData {
161    approved: bool,
162
163    #[serde(rename = "sessionToken")]
164    session_token: Option<String>,
165}
166
167#[derive(Clone, Deserialize)]
168struct WebLoginSessionResponseApproved {
169    session_token: String,
170}
171
172impl WebLoginSessionResponse {
173    fn approved(self) -> anyhow::Result<Option<WebLoginSessionResponseApproved>> {
174        if !self.success {
175            return Err(anyhow::anyhow!(self
176                .error
177                .clone()
178                .unwrap_or("Unknown error".to_string())));
179        }
180
181        let data = self.data.ok_or(anyhow::anyhow!("Response data is missing."))?;
182        if !data.approved {
183            // Approved is false, no session token expected
184            return Ok(None);
185        }
186
187        let session_token = data
188            .session_token
189            .ok_or(anyhow::anyhow!("Session token is missing in response.".to_string()))?;
190        Ok(Some(WebLoginSessionResponseApproved {
191            session_token: session_token.clone(),
192        }))
193    }
194}
195
196async fn web_login(remote: &Url) -> Result<String, anyhow::Error> {
197    let client = reqwest::Client::new();
198
199    let response: WebLoginTokenResponse = client
200        .post(remote.join("/api/auth/cli/login/request-token")?)
201        .send()
202        .await?
203        .json()
204        .await?;
205
206    if !response.success {
207        return Err(anyhow::anyhow!("Failed to request token"));
208    }
209
210    let web_login_request_token = response.data.token.as_str();
211
212    let mut browser_url = remote.join("login/cli")?;
213    browser_url
214        .query_pairs_mut()
215        .append_pair("token", web_login_request_token);
216    println!("Opening {browser_url} in your browser.");
217    if webbrowser::open(browser_url.as_str()).is_err() {
218        println!("Unable to open your browser! Please open the URL above manually.");
219    }
220
221    println!("Waiting to hear response from the server...");
222    loop {
223        tokio::time::sleep(std::time::Duration::from_secs(1)).await;
224
225        let mut status_url = remote.join("api/auth/cli/status")?;
226        status_url
227            .query_pairs_mut()
228            .append_pair("token", web_login_request_token);
229        let response: WebLoginSessionResponse = client.get(status_url).send().await?.json().await?;
230        if let Some(approved) = response.approved()? {
231            println!("Login successful!");
232            return Ok(approved.session_token.clone());
233        }
234    }
235}
236
237#[derive(Deserialize, Debug)]
238struct SpacetimeDBTokenResponse {
239    success: bool,
240    error: Option<String>,
241    data: Option<SpacetimeDBTokenData>,
242}
243
244#[derive(Deserialize, Debug)]
245struct SpacetimeDBTokenData {
246    token: String,
247}
248
249async fn spacetimedb_login(remote: &Url, web_session_token: &String) -> Result<String, anyhow::Error> {
250    let client = reqwest::Client::new();
251
252    let response: SpacetimeDBTokenResponse = client
253        .post(remote.join("api/spacetimedb-token")?)
254        .header("Authorization", format!("Bearer {web_session_token}"))
255        .send()
256        .await?
257        .json()
258        .await?;
259
260    if !response.success {
261        return Err(anyhow::anyhow!(
262            "Failed to get token: {}",
263            response.error.unwrap_or("Unknown error".to_string())
264        ));
265    }
266    Ok(response.data.unwrap().token.clone())
267}
268
269#[derive(Debug, Clone, Deserialize)]
270struct LocalLoginResponse {
271    pub token: String,
272}
273
274async fn spacetimedb_direct_login(host: &Url) -> Result<String, anyhow::Error> {
275    let client = reqwest::Client::new();
276    let response: LocalLoginResponse = client
277        .post(host.join("/v1/identity")?)
278        .send()
279        .await?
280        .error_for_status()?
281        .json()
282        .await?;
283    Ok(response.token)
284}