spacetimedb_cli/subcommands/
login.rs1use 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 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 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 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}