From 1882771db5490f92ef6e34995ae9d3f21367cec3 Mon Sep 17 00:00:00 2001 From: Julien Lavocat Date: Mon, 8 Jun 2026 14:32:06 -0400 Subject: [PATCH 1/5] Add spacetime auth CLI interface --- .github/CODEOWNERS | 2 + Cargo.lock | 1 + crates/cli/Cargo.toml | 1 + crates/cli/src/lib.rs | 2 + crates/cli/src/subcommands/auth.rs | 700 ++++++++++++++++++ crates/cli/src/subcommands/mod.rs | 1 + .../00100-cli-reference.md | 217 ++++++ 7 files changed, 924 insertions(+) create mode 100644 crates/cli/src/subcommands/auth.rs diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 55f4b73619b..598275cab63 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -6,6 +6,8 @@ LICENSE @cloutiertyler @jdetter LICENSE.txt @cloutiertyler @jdetter /licenses/ @cloutiertyler @jdetter +/crates/cli/src/subcommands/auth.rs @julienlavocat + /tools/ci/ @bfops @cloutiertyler @jdetter /tools/ci/commands/keynote-bench/ @joshua-spacetime @cloutiertyler @jdetter /tools/ci/commands/codeowners-check/ @cloutiertyler diff --git a/Cargo.lock b/Cargo.lock index 1c615864ea5..d0b0eb81a0e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8135,6 +8135,7 @@ dependencies = [ "tokio-tungstenite 0.27.0", "toml 0.8.23", "toml_edit 0.22.27", + "url", "walkdir", "wasmbin", "webbrowser", diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 4ca6467996d..42f01b8dd6d 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -67,6 +67,7 @@ thiserror.workspace = true tokio.workspace = true tokio-tungstenite.workspace = true toml_edit.workspace = true +url.workspace = true walkdir.workspace = true wasmbin.workspace = true webbrowser.workspace = true diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 5357e8d067b..a7d6f809aca 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -43,6 +43,7 @@ pub fn get_subcommands() -> Vec { start::cli(), lock::cli(), unlock::cli(), + auth::cli(), subcommands::version::cli(), ] } @@ -76,6 +77,7 @@ pub async fn exec_subcommand( "logout" => logout::exec(config, args).await, "lock" => lock::exec(config, args).await, "unlock" => unlock::exec(config, args).await, + "auth" => auth::exec(config, paths, args).await, "version" => return subcommands::version::exec(paths, root_dir, args).await, unknown => Err(anyhow::anyhow!("Invalid subcommand: {unknown}")), } diff --git a/crates/cli/src/subcommands/auth.rs b/crates/cli/src/subcommands/auth.rs new file mode 100644 index 00000000000..aa445ad05d8 --- /dev/null +++ b/crates/cli/src/subcommands/auth.rs @@ -0,0 +1,700 @@ +use crate::Config; +use clap::{Arg, ArgMatches, Command, ValueEnum}; +use reqwest::Client; +use spacetimedb_paths::SpacetimePaths; + +#[derive(Clone, Debug, ValueEnum)] +pub enum IdentityProvider { + Google, + Twitch, + Discord, + Kick, + Github, + Trackmania, +} + +impl std::fmt::Display for IdentityProvider { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.to_possible_value().unwrap().get_name()) + } +} + +#[derive(Clone, Debug, ValueEnum)] +pub enum ClientSetting { + Name, + Private, + Web, + Native, + #[value(name = "redirect_uris")] + RedirectUris, + #[value(name = "post_logout_redirect_uris")] + PostLogoutRedirectUris, +} + +impl std::fmt::Display for ClientSetting { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.to_possible_value().unwrap().get_name()) + } +} + +const DEFAULT_CLIENT_NAME: &str = "Default Client"; + +const SPACETIMEAUTH_API: &str = "https://spacetimedb.com/api/spacetimeauth/cli/"; + +async fn request(config: Config, body: &T) -> anyhow::Result +where + T: serde::Serialize, +{ + let url = std::env::var("SPACETIMEAUTH_API").unwrap_or_else(|_| SPACETIMEAUTH_API.to_string()); + let response = Client::builder() + .user_agent(concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"))) + .build() + .unwrap() + .request(reqwest::Method::POST, url) + .bearer_auth(config.web_session_token().expect("SpacetimeDB token required")) + .json(body) + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + anyhow::bail!("{status}: {text}"); + } + + Ok(response) +} + +#[derive(Clone, Debug, ValueEnum)] +pub enum AuthConfigSetting { + #[value(name = "display_name")] + DisplayName, + #[value(name = "favicon_url")] + FaviconUrl, + #[value(name = "color.text")] + ColorText, + #[value(name = "color.background")] + ColorBackground, + #[value(name = "color.primary")] + ColorPrimary, + #[value(name = "color.input")] + ColorInput, + #[value(name = "color.border")] + ColorBorder, + #[value(name = "login.email")] + LoginEmail, + #[value(name = "login.anonymous")] + LoginAnonymous, + #[value(name = "steam.publisher_key")] + SteamPublisherKey, + #[value(name = "steam.app_ids")] + SteamAppIds, +} + +impl std::fmt::Display for AuthConfigSetting { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.to_possible_value().unwrap().get_name()) + } +} + +pub fn cli() -> Command { + Command::new("auth") + .about("Manage SpacetimeAuth for a database") + .args_conflicts_with_subcommands(true) + .subcommand_required(true) + .subcommands(get_subcommands()) +} + +fn get_subcommands() -> Vec { + vec![ + Command::new("config") + .about("Manage SpacetimeAuth configuration for a database") + .subcommand_required(true) + .subcommand( + Command::new("set") + .about("Set a SpacetimeAuth configuration value for a database") + .arg( + Arg::new("database") + .required(true) + .help("The name or identity of the database"), + ) + .arg( + Arg::new("key") + .required(true) + .value_parser(clap::builder::EnumValueParser::::new()) + .help("The setting to configure"), + ) + .arg( + Arg::new("value") + .required(true) + .help("The value to assign to the setting"), + ), + ) + .subcommand( + Command::new("reset") + .about("Reset all SpacetimeAuth configuration for a database") + .arg( + Arg::new("database") + .required(true) + .help("The name or identity of the database"), + ), + ), + Command::new("idp") + .about("Manage identity providers for a database") + .subcommand_required(true) + .subcommand( + Command::new("set") + .about("Configure an identity provider for a database") + .arg( + Arg::new("database") + .required(true) + .help("The name or identity of the database"), + ) + .arg( + Arg::new("idp") + .required(true) + .value_parser(clap::builder::EnumValueParser::::new()) + .help("The identity provider to configure"), + ) + .arg(Arg::new("client_id").required(true).help("The OAuth client ID")) + .arg(Arg::new("client_secret").required(true).help("The OAuth client secret")), + ) + .subcommand(idp_toggle_command( + "enable", + "Enable an identity provider for a database", + )) + .subcommand(idp_toggle_command( + "disable", + "Disable an identity provider for a database", + )), + Command::new("client") + .about("Manage OAuth clients for SpacetimeAuth") + .subcommand_required(true) + .subcommand( + Command::new("create") + .about("Create a new OAuth client") + .arg( + Arg::new("name") + .required(false) + .default_value(DEFAULT_CLIENT_NAME) + .help("The client name"), + ) + .arg( + Arg::new("private") + .long("private") + .action(clap::ArgAction::SetTrue) + .help("Create the client as private (requires a client secret for token exchange)"), + ), + ) + .subcommand( + Command::new("delete").about("Delete an OAuth client").arg( + Arg::new("name") + .required(false) + .default_value(DEFAULT_CLIENT_NAME) + .help("The client name"), + ), + ) + .subcommand( + Command::new("get") + .about("Get an OAuth client") + .arg( + Arg::new("name") + .required(false) + .default_value(DEFAULT_CLIENT_NAME) + .help("The client name"), + ) + .arg( + Arg::new("include-secret") + .long("include-secret") + .action(clap::ArgAction::SetTrue) + .help("Include the client secret in the output"), + ), + ) + .subcommand( + Command::new("set") + .about("Set a configuration value for an OAuth client") + .after_help( + "ARGS:\n [name] Client name (default: \"Default Client\")\n \ + Setting to update: name, private, web, native, redirect_uris, \ + post_logout_redirect_uris\n Value to assign", + ) + .arg( + Arg::new("args") + .num_args(2..=3) + .required(true) + .value_names(["key", "value"]) + .help("[name] "), + ), + ), + ] +} + +fn idp_toggle_command(name: &'static str, about: &'static str) -> Command { + Command::new(name) + .about(about) + .arg( + Arg::new("database") + .required(true) + .help("The name or identity of the database"), + ) + .arg( + Arg::new("idp") + .required(true) + .value_parser(clap::builder::EnumValueParser::::new()) + .help("The identity provider to configure"), + ) +} + +pub async fn exec(config: Config, paths: &SpacetimePaths, args: &ArgMatches) -> Result<(), anyhow::Error> { + let (cmd, subcommand_args) = args.subcommand().expect("Subcommand required"); + exec_subcommand(config, paths, cmd, subcommand_args).await +} + +async fn exec_subcommand( + config: Config, + _paths: &SpacetimePaths, + cmd: &str, + args: &ArgMatches, +) -> Result<(), anyhow::Error> { + match cmd { + "config" => exec_config(config, args).await, + "idp" => exec_idp(config, args).await, + "client" => exec_client(config, args).await, + unknown => Err(anyhow::anyhow!("Invalid subcommand: {unknown}")), + } +} + +async fn exec_client(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { + let (cmd, subcommand_args) = args.subcommand().expect("Subcommand required"); + match cmd { + "create" => exec_client_create(config, subcommand_args).await, + "delete" => exec_client_delete(config, subcommand_args).await, + "get" => exec_client_get(config, subcommand_args).await, + "set" => exec_client_set(config, subcommand_args).await, + unknown => Err(anyhow::anyhow!("Invalid subcommand: {unknown}")), + } +} + +async fn exec_client_create(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { + let name = args.get_one::("name").unwrap(); + let private = args.get_flag("private"); + + let response = request( + config, + &serde_json::json!({ + "action": "client.create", + "name": name, + "private": private, + }), + ) + .await?; + + let response_text = response.text().await?; + println!("{response_text}"); + + Ok(()) +} + +async fn exec_client_delete(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { + let name = args.get_one::("name").unwrap(); + + let response = request( + config, + &serde_json::json!({ + "action": "client.delete", + "name": name, + }), + ) + .await?; + + let response_text = response.text().await?; + println!("{response_text}"); + + Ok(()) +} + +async fn exec_client_get(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { + let name = args.get_one::("name").unwrap(); + let include_secret = args.get_flag("include-secret"); + + let response = request( + config, + &serde_json::json!({ + "action": "client.get", + "name": name, + "include_secret": include_secret, + }), + ) + .await?; + + let response_text = response.text().await?; + println!("{response_text}"); + + Ok(()) +} + +async fn exec_client_set(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { + let raw: Vec<&String> = args.get_many::("args").unwrap().collect(); + + // Disambiguate [name] : with 2 args the name is omitted. + let (client_name, key_str, value_str) = match raw.as_slice() { + [key, value] => (DEFAULT_CLIENT_NAME, key.as_str(), value.as_str()), + [name, key, value] => (name.as_str(), key.as_str(), value.as_str()), + _ => unreachable!(), + }; + + let key = ClientSetting::from_str(key_str, true).map_err(|_| { + anyhow::anyhow!( + "invalid key: {key_str:?}. Valid keys: name, private, web, native, \ + redirect_uris, post_logout_redirect_uris" + ) + })?; + + validate_client_setting(&key, value_str)?; + + let response = request( + config, + &serde_json::json!({ + "action": "client.set", + "name": client_name, + "key": key.to_string(), + "value": value_str, + }), + ) + .await?; + + let response_text = response.text().await?; + println!("{response_text}"); + + Ok(()) +} + +fn validate_client_setting(key: &ClientSetting, value: &str) -> Result<(), anyhow::Error> { + match key { + ClientSetting::Name => { + anyhow::ensure!(!value.trim().is_empty(), "client name cannot be empty"); + } + ClientSetting::Private | ClientSetting::Web | ClientSetting::Native => { + anyhow::ensure!( + matches!(value.to_lowercase().as_str(), "true" | "false" | "1" | "0"), + "expected a boolean (true/false), got: {value:?}" + ); + } + ClientSetting::RedirectUris | ClientSetting::PostLogoutRedirectUris => { + for uri in value.split(',') { + let uri = uri.trim(); + anyhow::ensure!(!uri.is_empty(), "URI list must not contain empty entries"); + url::Url::parse(uri).map_err(|e| anyhow::anyhow!("invalid URI {uri:?}: {e}"))?; + } + } + } + Ok(()) +} + +async fn exec_config(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { + let (cmd, subcommand_args) = args.subcommand().expect("Subcommand required"); + match cmd { + "set" => exec_config_set(config, subcommand_args).await, + "reset" => exec_config_reset(config, subcommand_args).await, + unknown => Err(anyhow::anyhow!("Invalid subcommand: {unknown}")), + } +} + +async fn exec_config_set(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { + let database = args.get_one::("database").unwrap(); + let key = args.get_one::("key").unwrap(); + let value = args.get_one::("value").unwrap(); + + validate_value(key, value)?; + + let response = request( + config, + &serde_json::json!({ + "action": "config.set", + "database": database, + "key": key.to_string(), + "value": value, + }), + ) + .await?; + + let response_text = response.text().await?; + println!("{response_text}"); + + Ok(()) +} + +async fn exec_config_reset(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { + let database = args.get_one::("database").unwrap(); + + let response = request( + config, + &serde_json::json!({ + "action": "config.reset", + "database": database, + }), + ) + .await?; + + let response_text = response.text().await?; + println!("{response_text}"); + + Ok(()) +} + +async fn exec_idp(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { + let (cmd, subcommand_args) = args.subcommand().expect("Subcommand required"); + match cmd { + "set" => exec_idp_set(config, subcommand_args).await, + "enable" => exec_idp_toggle(config, subcommand_args, true).await, + "disable" => exec_idp_toggle(config, subcommand_args, false).await, + unknown => Err(anyhow::anyhow!("Invalid subcommand: {unknown}")), + } +} + +async fn exec_idp_set(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { + let database = args.get_one::("database").unwrap(); + let idp = args.get_one::("idp").unwrap(); + let client_id = args.get_one::("client_id").unwrap(); + let client_secret = args.get_one::("client_secret").unwrap(); + + let response = request( + config, + &serde_json::json!({ + "action": "idp.set", + "database": database, + "idp": idp.to_string(), + "client_id": client_id, + "client_secret": client_secret, + }), + ) + .await?; + + let response_text = response.text().await?; + println!("{response_text}"); + + Ok(()) +} + +async fn exec_idp_toggle(config: Config, args: &ArgMatches, enabled: bool) -> Result<(), anyhow::Error> { + let database = args.get_one::("database").unwrap(); + let idp = args.get_one::("idp").unwrap(); + + let response = request( + config, + &serde_json::json!({ + "action": "idp.toggle", + "database": database, + "idp": idp.to_string(), + "enabled": enabled, + }), + ) + .await?; + + let response_text = response.text().await?; + println!("{response_text}"); + + Ok(()) +} + +fn validate_value(setting: &AuthConfigSetting, value: &str) -> Result<(), anyhow::Error> { + match setting { + AuthConfigSetting::ColorText + | AuthConfigSetting::ColorBackground + | AuthConfigSetting::ColorPrimary + | AuthConfigSetting::ColorInput + | AuthConfigSetting::ColorBorder => { + anyhow::ensure!(is_valid_css_color(value), "invalid CSS color: {value:?}"); + } + AuthConfigSetting::FaviconUrl => { + url::Url::parse(value).map_err(|e| anyhow::anyhow!("invalid URL: {e}"))?; + } + AuthConfigSetting::LoginEmail | AuthConfigSetting::LoginAnonymous => { + anyhow::ensure!( + matches!(value.to_lowercase().as_str(), "true" | "false" | "1" | "0"), + "expected a boolean (true/false), got: {value:?}" + ); + } + AuthConfigSetting::SteamAppIds => { + for part in value.split(',') { + let part = part.trim(); + anyhow::ensure!(!part.is_empty(), "Steam app ID list must not contain empty entries"); + part.parse::() + .map_err(|_| anyhow::anyhow!("invalid Steam app ID: {part:?}, expected a positive integer"))?; + } + } + AuthConfigSetting::DisplayName | AuthConfigSetting::SteamPublisherKey => {} + } + Ok(()) +} + +fn is_valid_css_color(value: &str) -> bool { + const NAMED: &[&str] = &[ + "aliceblue", + "antiquewhite", + "aqua", + "aquamarine", + "azure", + "beige", + "bisque", + "black", + "blanchedalmond", + "blue", + "blueviolet", + "brown", + "burlywood", + "cadetblue", + "chartreuse", + "chocolate", + "coral", + "cornflowerblue", + "cornsilk", + "crimson", + "cyan", + "darkblue", + "darkcyan", + "darkgoldenrod", + "darkgray", + "darkgreen", + "darkgrey", + "darkkhaki", + "darkmagenta", + "darkolivegreen", + "darkorange", + "darkorchid", + "darkred", + "darksalmon", + "darkseagreen", + "darkslateblue", + "darkslategray", + "darkslategrey", + "darkturquoise", + "darkviolet", + "deeppink", + "deepskyblue", + "dimgray", + "dimgrey", + "dodgerblue", + "firebrick", + "floralwhite", + "forestgreen", + "fuchsia", + "gainsboro", + "ghostwhite", + "gold", + "goldenrod", + "gray", + "green", + "greenyellow", + "grey", + "honeydew", + "hotpink", + "indianred", + "indigo", + "ivory", + "khaki", + "lavender", + "lavenderblush", + "lawngreen", + "lemonchiffon", + "lightblue", + "lightcoral", + "lightcyan", + "lightgoldenrodyellow", + "lightgray", + "lightgreen", + "lightgrey", + "lightpink", + "lightsalmon", + "lightseagreen", + "lightskyblue", + "lightslategray", + "lightslategrey", + "lightsteelblue", + "lightyellow", + "lime", + "limegreen", + "linen", + "magenta", + "maroon", + "mediumaquamarine", + "mediumblue", + "mediumorchid", + "mediumpurple", + "mediumseagreen", + "mediumslateblue", + "mediumspringgreen", + "mediumturquoise", + "mediumvioletred", + "midnightblue", + "mintcream", + "mistyrose", + "moccasin", + "navajowhite", + "navy", + "oldlace", + "olive", + "olivedrab", + "orange", + "orangered", + "orchid", + "palegoldenrod", + "palegreen", + "paleturquoise", + "palevioletred", + "papayawhip", + "peachpuff", + "peru", + "pink", + "plum", + "powderblue", + "purple", + "rebeccapurple", + "red", + "rosybrown", + "royalblue", + "saddlebrown", + "salmon", + "sandybrown", + "seagreen", + "seashell", + "sienna", + "silver", + "skyblue", + "slateblue", + "slategray", + "slategrey", + "snow", + "springgreen", + "steelblue", + "tan", + "teal", + "thistle", + "tomato", + "transparent", + "turquoise", + "violet", + "wheat", + "white", + "whitesmoke", + "yellow", + "yellowgreen", + ]; + + let lower = value.to_lowercase(); + + if NAMED.contains(&lower.as_str()) { + return true; + } + + if let Some(hex) = lower.strip_prefix('#') { + let n = hex.len(); + return (n == 3 || n == 4 || n == 6 || n == 8) && hex.chars().all(|c| c.is_ascii_hexdigit()); + } + + // rgb(), rgba(), hsl(), hsla() + let func_re = + regex::Regex::new(r"(?i)^(rgba?|hsla?)\(\s*[\d.]+%?\s*,\s*[\d.]+%?\s*,\s*[\d.]+%?\s*(?:,\s*[\d.]+\s*)?\)$") + .unwrap(); + func_re.is_match(value) +} diff --git a/crates/cli/src/subcommands/mod.rs b/crates/cli/src/subcommands/mod.rs index 9c659a5880f..c3870aae636 100644 --- a/crates/cli/src/subcommands/mod.rs +++ b/crates/cli/src/subcommands/mod.rs @@ -1,3 +1,4 @@ +pub mod auth; pub mod build; pub mod call; pub mod db_arg_resolution; diff --git a/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md b/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md index b7786a7feff..3cc1b80f5b7 100644 --- a/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md +++ b/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md @@ -42,6 +42,19 @@ This document contains the help content for the `spacetime` command-line program * [`spacetime start`↴](#spacetime-start) * [`spacetime lock`↴](#spacetime-lock) * [`spacetime unlock`↴](#spacetime-unlock) +* [`spacetime auth`↴](#spacetime-auth) +* [`spacetime auth config`↴](#spacetime-auth-config) +* [`spacetime auth config set`↴](#spacetime-auth-config-set) +* [`spacetime auth config reset`↴](#spacetime-auth-config-reset) +* [`spacetime auth idp`↴](#spacetime-auth-idp) +* [`spacetime auth idp set`↴](#spacetime-auth-idp-set) +* [`spacetime auth idp enable`↴](#spacetime-auth-idp-enable) +* [`spacetime auth idp disable`↴](#spacetime-auth-idp-disable) +* [`spacetime auth client`↴](#spacetime-auth-client) +* [`spacetime auth client create`↴](#spacetime-auth-client-create) +* [`spacetime auth client delete`↴](#spacetime-auth-client-delete) +* [`spacetime auth client get`↴](#spacetime-auth-client-get) +* [`spacetime auth client set`↴](#spacetime-auth-client-set) * [`spacetime version`↴](#spacetime-version) ## `spacetime` @@ -71,6 +84,7 @@ This document contains the help content for the `spacetime` command-line program * `start` — Start a local SpacetimeDB instance * `lock` — Lock a database to prevent accidental deletion * `unlock` — Unlock a database to allow deletion +* `auth` — Manage SpacetimeAuth for a database * `version` — Manage installed spacetime versions ###### **Options:** @@ -797,6 +811,209 @@ Run `spacetime help unlock` for more detailed information. +## `spacetime auth` + +Manage SpacetimeAuth for a database + +**Usage:** `spacetime auth + auth \` + +###### **Subcommands:** + +* `config` — Manage SpacetimeAuth configuration for a database +* `idp` — Manage identity providers for a database +* `client` — Manage OAuth clients for SpacetimeAuth + + + +## `spacetime auth config` + +Manage SpacetimeAuth configuration for a database + +**Usage:** `spacetime auth config ` + +###### **Subcommands:** + +* `set` — Set a SpacetimeAuth configuration value for a database +* `reset` — Reset all SpacetimeAuth configuration for a database + + + +## `spacetime auth config set` + +Set a SpacetimeAuth configuration value for a database + +**Usage:** `spacetime auth config set ` + +###### **Arguments:** + +* `` — The name or identity of the database +* `` — The setting to configure + + Possible values: `display_name`, `favicon_url`, `color.text`, `color.background`, `color.primary`, `color.input`, `color.border`, `login.email`, `login.anonymous`, `steam.publisher_key`, `steam.app_ids` + +* `` — The value to assign to the setting + + + +## `spacetime auth config reset` + +Reset all SpacetimeAuth configuration for a database + +**Usage:** `spacetime auth config reset ` + +###### **Arguments:** + +* `` — The name or identity of the database + + + +## `spacetime auth idp` + +Manage identity providers for a database + +**Usage:** `spacetime auth idp ` + +###### **Subcommands:** + +* `set` — Configure an identity provider for a database +* `enable` — Enable an identity provider for a database +* `disable` — Disable an identity provider for a database + + + +## `spacetime auth idp set` + +Configure an identity provider for a database + +**Usage:** `spacetime auth idp set ` + +###### **Arguments:** + +* `` — The name or identity of the database +* `` — The identity provider to configure + + Possible values: `google`, `twitch`, `discord`, `kick`, `github`, `trackmania` + +* `` — The OAuth client ID +* `` — The OAuth client secret + + + +## `spacetime auth idp enable` + +Enable an identity provider for a database + +**Usage:** `spacetime auth idp enable ` + +###### **Arguments:** + +* `` — The name or identity of the database +* `` — The identity provider to configure + + Possible values: `google`, `twitch`, `discord`, `kick`, `github`, `trackmania` + + + + +## `spacetime auth idp disable` + +Disable an identity provider for a database + +**Usage:** `spacetime auth idp disable ` + +###### **Arguments:** + +* `` — The name or identity of the database +* `` — The identity provider to configure + + Possible values: `google`, `twitch`, `discord`, `kick`, `github`, `trackmania` + + + + +## `spacetime auth client` + +Manage OAuth clients for SpacetimeAuth + +**Usage:** `spacetime auth client ` + +###### **Subcommands:** + +* `create` — Create a new OAuth client +* `delete` — Delete an OAuth client +* `get` — Get an OAuth client +* `set` — Set a configuration value for an OAuth client + + + +## `spacetime auth client create` + +Create a new OAuth client + +**Usage:** `spacetime auth client create [OPTIONS] [name]` + +###### **Arguments:** + +* `` — The client name + + Default value: `Default Client` + +###### **Options:** + +* `--private` — Create the client as private (requires a client secret for token exchange) + + + +## `spacetime auth client delete` + +Delete an OAuth client + +**Usage:** `spacetime auth client delete [name]` + +###### **Arguments:** + +* `` — The client name + + Default value: `Default Client` + + + +## `spacetime auth client get` + +Get an OAuth client + +**Usage:** `spacetime auth client get [OPTIONS] [name]` + +###### **Arguments:** + +* `` — The client name + + Default value: `Default Client` + +###### **Options:** + +* `--include-secret` — Include the client secret in the output + + + +## `spacetime auth client set` + +Set a configuration value for an OAuth client + +**Usage:** `spacetime auth client set ...` + +ARGS: + [name] Client name (default: "Default Client") + Setting to update: name, private, web, native, redirect_uris, post_logout_redirect_uris + Value to assign + +###### **Arguments:** + +* `` — [name] + + + ## `spacetime version` Manage installed spacetime versions From 0d5baaeb0bc915bb1290d90993a28bbc83512e7e Mon Sep 17 00:00:00 2001 From: Julien Lavocat Date: Tue, 29 Sep 2026 17:59:06 +0200 Subject: [PATCH 2/5] Fix spacetime auth client commands and simplify validation The client subcommands never sent `database`, which the server requires, so every client request was rejected. They now take a `` argument and a `--name` option instead of the ambiguous positional name. - Error instead of panicking when not logged in - Derive the API URL from DEFAULT_AUTH_HOST (no trailing slash) - Leave color and favicon validation to the server - Allow empty values to clear list settings - Pretty-print responses and collapse the per-command handlers - Add unit tests; regenerate the CLI reference - Remove duplicated CODEOWNERS entry --- crates/cli/src/lib.rs | 2 +- crates/cli/src/subcommands/auth.rs | 765 ++++++------------ .../00100-cli-reference.md | 63 +- 3 files changed, 286 insertions(+), 544 deletions(-) diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index a7d6f809aca..1aa171f8d71 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -77,7 +77,7 @@ pub async fn exec_subcommand( "logout" => logout::exec(config, args).await, "lock" => lock::exec(config, args).await, "unlock" => unlock::exec(config, args).await, - "auth" => auth::exec(config, paths, args).await, + "auth" => auth::exec(config, args).await, "version" => return subcommands::version::exec(paths, root_dir, args).await, unknown => Err(anyhow::anyhow!("Invalid subcommand: {unknown}")), } diff --git a/crates/cli/src/subcommands/auth.rs b/crates/cli/src/subcommands/auth.rs index aa445ad05d8..972cb4a63a3 100644 --- a/crates/cli/src/subcommands/auth.rs +++ b/crates/cli/src/subcommands/auth.rs @@ -1,7 +1,7 @@ +use crate::login::DEFAULT_AUTH_HOST; use crate::Config; use clap::{Arg, ArgMatches, Command, ValueEnum}; use reqwest::Client; -use spacetimedb_paths::SpacetimePaths; #[derive(Clone, Debug, ValueEnum)] pub enum IdentityProvider { @@ -37,34 +37,6 @@ impl std::fmt::Display for ClientSetting { } } -const DEFAULT_CLIENT_NAME: &str = "Default Client"; - -const SPACETIMEAUTH_API: &str = "https://spacetimedb.com/api/spacetimeauth/cli/"; - -async fn request(config: Config, body: &T) -> anyhow::Result -where - T: serde::Serialize, -{ - let url = std::env::var("SPACETIMEAUTH_API").unwrap_or_else(|_| SPACETIMEAUTH_API.to_string()); - let response = Client::builder() - .user_agent(concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"))) - .build() - .unwrap() - .request(reqwest::Method::POST, url) - .bearer_auth(config.web_session_token().expect("SpacetimeDB token required")) - .json(body) - .send() - .await?; - - if !response.status().is_success() { - let status = response.status(); - let text = response.text().await.unwrap_or_default(); - anyhow::bail!("{status}: {text}"); - } - - Ok(response) -} - #[derive(Clone, Debug, ValueEnum)] pub enum AuthConfigSetting { #[value(name = "display_name")] @@ -97,10 +69,65 @@ impl std::fmt::Display for AuthConfigSetting { } } +const DEFAULT_CLIENT_NAME: &str = "Default Client"; + +/// Overrides the SpacetimeAuth management endpoint, e.g. to target a local dev server. +const SPACETIMEAUTH_API_ENV: &str = "SPACETIMEAUTH_API"; + +fn api_url() -> String { + std::env::var(SPACETIMEAUTH_API_ENV).unwrap_or_else(|_| format!("{DEFAULT_AUTH_HOST}/api/spacetimeauth/cli")) +} + +/// Sends `body` to the SpacetimeAuth management API and prints the response. +async fn send(config: &Config, body: serde_json::Value) -> anyhow::Result<()> { + let token = config + .web_session_token() + .ok_or_else(|| anyhow::anyhow!("You are not logged in. Run `spacetime login` first."))?; + + let response = Client::builder() + .user_agent(concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"))) + .build()? + .post(api_url()) + .bearer_auth(token) + .json(&body) + .send() + .await?; + + let status = response.status(); + let text = response.text().await?; + if !status.is_success() { + anyhow::bail!("{status}: {text}"); + } + + match serde_json::from_str::(&text) { + Ok(json) => println!("{}", serde_json::to_string_pretty(&json)?), + Err(_) => println!("{text}"), + } + + Ok(()) +} + +fn database_arg() -> Arg { + Arg::new("database").required(true).help("The name of the database") +} + +fn idp_arg() -> Arg { + Arg::new("idp") + .required(true) + .value_parser(clap::builder::EnumValueParser::::new()) + .help("The identity provider to configure") +} + +fn client_name_arg() -> Arg { + Arg::new("name") + .long("name") + .default_value(DEFAULT_CLIENT_NAME) + .help("The client name") +} + pub fn cli() -> Command { Command::new("auth") .about("Manage SpacetimeAuth for a database") - .args_conflicts_with_subcommands(true) .subcommand_required(true) .subcommands(get_subcommands()) } @@ -113,11 +140,7 @@ fn get_subcommands() -> Vec { .subcommand( Command::new("set") .about("Set a SpacetimeAuth configuration value for a database") - .arg( - Arg::new("database") - .required(true) - .help("The name or identity of the database"), - ) + .arg(database_arg()) .arg( Arg::new("key") .required(true) @@ -133,11 +156,7 @@ fn get_subcommands() -> Vec { .subcommand( Command::new("reset") .about("Reset all SpacetimeAuth configuration for a database") - .arg( - Arg::new("database") - .required(true) - .help("The name or identity of the database"), - ), + .arg(database_arg()), ), Command::new("idp") .about("Manage identity providers for a database") @@ -145,40 +164,31 @@ fn get_subcommands() -> Vec { .subcommand( Command::new("set") .about("Configure an identity provider for a database") - .arg( - Arg::new("database") - .required(true) - .help("The name or identity of the database"), - ) - .arg( - Arg::new("idp") - .required(true) - .value_parser(clap::builder::EnumValueParser::::new()) - .help("The identity provider to configure"), - ) + .arg(database_arg()) + .arg(idp_arg()) .arg(Arg::new("client_id").required(true).help("The OAuth client ID")) .arg(Arg::new("client_secret").required(true).help("The OAuth client secret")), ) - .subcommand(idp_toggle_command( - "enable", - "Enable an identity provider for a database", - )) - .subcommand(idp_toggle_command( - "disable", - "Disable an identity provider for a database", - )), + .subcommand( + Command::new("enable") + .about("Enable an identity provider for a database") + .arg(database_arg()) + .arg(idp_arg()), + ) + .subcommand( + Command::new("disable") + .about("Disable an identity provider for a database") + .arg(database_arg()) + .arg(idp_arg()), + ), Command::new("client") - .about("Manage OAuth clients for SpacetimeAuth") + .about("Manage OAuth clients for a database") .subcommand_required(true) .subcommand( Command::new("create") .about("Create a new OAuth client") - .arg( - Arg::new("name") - .required(false) - .default_value(DEFAULT_CLIENT_NAME) - .help("The client name"), - ) + .arg(database_arg()) + .arg(client_name_arg()) .arg( Arg::new("private") .long("private") @@ -187,22 +197,16 @@ fn get_subcommands() -> Vec { ), ) .subcommand( - Command::new("delete").about("Delete an OAuth client").arg( - Arg::new("name") - .required(false) - .default_value(DEFAULT_CLIENT_NAME) - .help("The client name"), - ), + Command::new("delete") + .about("Delete an OAuth client") + .arg(database_arg()) + .arg(client_name_arg()), ) .subcommand( Command::new("get") .about("Get an OAuth client") - .arg( - Arg::new("name") - .required(false) - .default_value(DEFAULT_CLIENT_NAME) - .help("The client name"), - ) + .arg(database_arg()) + .arg(client_name_arg()) .arg( Arg::new("include-secret") .long("include-secret") @@ -213,488 +217,215 @@ fn get_subcommands() -> Vec { .subcommand( Command::new("set") .about("Set a configuration value for an OAuth client") - .after_help( - "ARGS:\n [name] Client name (default: \"Default Client\")\n \ - Setting to update: name, private, web, native, redirect_uris, \ - post_logout_redirect_uris\n Value to assign", + .arg(database_arg()) + .arg( + Arg::new("key") + .required(true) + .value_parser(clap::builder::EnumValueParser::::new()) + .help("The setting to configure"), ) .arg( - Arg::new("args") - .num_args(2..=3) + Arg::new("value") .required(true) - .value_names(["key", "value"]) - .help("[name] "), - ), + .help("The value to assign to the setting"), + ) + .arg(client_name_arg()), ), ] } -fn idp_toggle_command(name: &'static str, about: &'static str) -> Command { - Command::new(name) - .about(about) - .arg( - Arg::new("database") - .required(true) - .help("The name or identity of the database"), - ) - .arg( - Arg::new("idp") - .required(true) - .value_parser(clap::builder::EnumValueParser::::new()) - .help("The identity provider to configure"), - ) -} - -pub async fn exec(config: Config, paths: &SpacetimePaths, args: &ArgMatches) -> Result<(), anyhow::Error> { - let (cmd, subcommand_args) = args.subcommand().expect("Subcommand required"); - exec_subcommand(config, paths, cmd, subcommand_args).await -} - -async fn exec_subcommand( - config: Config, - _paths: &SpacetimePaths, - cmd: &str, - args: &ArgMatches, -) -> Result<(), anyhow::Error> { - match cmd { - "config" => exec_config(config, args).await, - "idp" => exec_idp(config, args).await, - "client" => exec_client(config, args).await, - unknown => Err(anyhow::anyhow!("Invalid subcommand: {unknown}")), - } -} - -async fn exec_client(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { - let (cmd, subcommand_args) = args.subcommand().expect("Subcommand required"); - match cmd { - "create" => exec_client_create(config, subcommand_args).await, - "delete" => exec_client_delete(config, subcommand_args).await, - "get" => exec_client_get(config, subcommand_args).await, - "set" => exec_client_set(config, subcommand_args).await, - unknown => Err(anyhow::anyhow!("Invalid subcommand: {unknown}")), - } -} - -async fn exec_client_create(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { - let name = args.get_one::("name").unwrap(); - let private = args.get_flag("private"); +pub async fn exec(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { + let (cmd, args) = args.subcommand().expect("Subcommand required"); + let (subcmd, args) = args.subcommand().expect("Subcommand required"); + let database = args.get_one::("database").unwrap(); - let response = request( - config, - &serde_json::json!({ + let body = match (cmd, subcmd) { + ("config", "set") => { + let key = args.get_one::("key").unwrap(); + let value = args.get_one::("value").unwrap(); + validate_config_setting(key, value)?; + serde_json::json!({ + "action": "config.set", + "database": database, + "key": key.to_string(), + "value": value, + }) + } + ("config", "reset") => serde_json::json!({ + "action": "config.reset", + "database": database, + }), + ("idp", "set") => serde_json::json!({ + "action": "idp.set", + "database": database, + "idp": args.get_one::("idp").unwrap().to_string(), + "client_id": args.get_one::("client_id").unwrap(), + "client_secret": args.get_one::("client_secret").unwrap(), + }), + ("idp", toggle @ ("enable" | "disable")) => serde_json::json!({ + "action": "idp.toggle", + "database": database, + "idp": args.get_one::("idp").unwrap().to_string(), + "enabled": toggle == "enable", + }), + ("client", "create") => serde_json::json!({ "action": "client.create", - "name": name, - "private": private, + "database": database, + "name": args.get_one::("name").unwrap(), + "private": args.get_flag("private"), }), - ) - .await?; - - let response_text = response.text().await?; - println!("{response_text}"); - - Ok(()) -} - -async fn exec_client_delete(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { - let name = args.get_one::("name").unwrap(); - - let response = request( - config, - &serde_json::json!({ + ("client", "delete") => serde_json::json!({ "action": "client.delete", - "name": name, + "database": database, + "name": args.get_one::("name").unwrap(), }), - ) - .await?; - - let response_text = response.text().await?; - println!("{response_text}"); - - Ok(()) -} - -async fn exec_client_get(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { - let name = args.get_one::("name").unwrap(); - let include_secret = args.get_flag("include-secret"); - - let response = request( - config, - &serde_json::json!({ + ("client", "get") => serde_json::json!({ "action": "client.get", - "name": name, - "include_secret": include_secret, + "database": database, + "name": args.get_one::("name").unwrap(), + "include_secret": args.get_flag("include-secret"), }), - ) - .await?; + ("client", "set") => { + let key = args.get_one::("key").unwrap(); + let value = args.get_one::("value").unwrap(); + validate_client_setting(key, value)?; + serde_json::json!({ + "action": "client.set", + "database": database, + "name": args.get_one::("name").unwrap(), + "key": key.to_string(), + "value": value, + }) + } + (cmd, subcmd) => anyhow::bail!("Invalid subcommand: {cmd} {subcmd}"), + }; - let response_text = response.text().await?; - println!("{response_text}"); + send(&config, body).await +} +fn validate_bool(value: &str) -> anyhow::Result<()> { + anyhow::ensure!( + matches!(value.to_lowercase().as_str(), "true" | "false" | "1" | "0"), + "expected a boolean (true/false), got: {value:?}" + ); Ok(()) } -async fn exec_client_set(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { - let raw: Vec<&String> = args.get_many::("args").unwrap().collect(); - - // Disambiguate [name] : with 2 args the name is omitted. - let (client_name, key_str, value_str) = match raw.as_slice() { - [key, value] => (DEFAULT_CLIENT_NAME, key.as_str(), value.as_str()), - [name, key, value] => (name.as_str(), key.as_str(), value.as_str()), - _ => unreachable!(), - }; - - let key = ClientSetting::from_str(key_str, true).map_err(|_| { - anyhow::anyhow!( - "invalid key: {key_str:?}. Valid keys: name, private, web, native, \ - redirect_uris, post_logout_redirect_uris" - ) - })?; - - validate_client_setting(&key, value_str)?; - - let response = request( - config, - &serde_json::json!({ - "action": "client.set", - "name": client_name, - "key": key.to_string(), - "value": value_str, - }), - ) - .await?; - - let response_text = response.text().await?; - println!("{response_text}"); - +/// Validates each entry of a comma-separated list. An empty value clears the list. +fn validate_list(value: &str, validate_entry: impl Fn(&str) -> anyhow::Result<()>) -> anyhow::Result<()> { + if value.trim().is_empty() { + return Ok(()); + } + for entry in value.split(',') { + let entry = entry.trim(); + anyhow::ensure!(!entry.is_empty(), "list must not contain empty entries: {value:?}"); + validate_entry(entry)?; + } Ok(()) } -fn validate_client_setting(key: &ClientSetting, value: &str) -> Result<(), anyhow::Error> { +fn validate_client_setting(key: &ClientSetting, value: &str) -> anyhow::Result<()> { match key { ClientSetting::Name => { anyhow::ensure!(!value.trim().is_empty(), "client name cannot be empty"); } - ClientSetting::Private | ClientSetting::Web | ClientSetting::Native => { - anyhow::ensure!( - matches!(value.to_lowercase().as_str(), "true" | "false" | "1" | "0"), - "expected a boolean (true/false), got: {value:?}" - ); - } - ClientSetting::RedirectUris | ClientSetting::PostLogoutRedirectUris => { - for uri in value.split(',') { - let uri = uri.trim(); - anyhow::ensure!(!uri.is_empty(), "URI list must not contain empty entries"); - url::Url::parse(uri).map_err(|e| anyhow::anyhow!("invalid URI {uri:?}: {e}"))?; - } - } + ClientSetting::Private | ClientSetting::Web | ClientSetting::Native => validate_bool(value)?, + ClientSetting::RedirectUris | ClientSetting::PostLogoutRedirectUris => validate_list(value, |uri| { + url::Url::parse(uri).map_err(|e| anyhow::anyhow!("invalid URI {uri:?}: {e}"))?; + Ok(()) + })?, } Ok(()) } -async fn exec_config(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { - let (cmd, subcommand_args) = args.subcommand().expect("Subcommand required"); - match cmd { - "set" => exec_config_set(config, subcommand_args).await, - "reset" => exec_config_reset(config, subcommand_args).await, - unknown => Err(anyhow::anyhow!("Invalid subcommand: {unknown}")), +fn validate_config_setting(key: &AuthConfigSetting, value: &str) -> anyhow::Result<()> { + match key { + AuthConfigSetting::LoginEmail | AuthConfigSetting::LoginAnonymous => validate_bool(value)?, + AuthConfigSetting::SteamAppIds => validate_list(value, |id| { + id.parse::() + .map_err(|_| anyhow::anyhow!("invalid Steam app ID: {id:?}, expected a positive integer"))?; + Ok(()) + })?, + // Validated by the server. + AuthConfigSetting::DisplayName + | AuthConfigSetting::FaviconUrl + | AuthConfigSetting::ColorText + | AuthConfigSetting::ColorBackground + | AuthConfigSetting::ColorPrimary + | AuthConfigSetting::ColorInput + | AuthConfigSetting::ColorBorder + | AuthConfigSetting::SteamPublisherKey => {} } -} - -async fn exec_config_set(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { - let database = args.get_one::("database").unwrap(); - let key = args.get_one::("key").unwrap(); - let value = args.get_one::("value").unwrap(); - - validate_value(key, value)?; - - let response = request( - config, - &serde_json::json!({ - "action": "config.set", - "database": database, - "key": key.to_string(), - "value": value, - }), - ) - .await?; - - let response_text = response.text().await?; - println!("{response_text}"); - Ok(()) } -async fn exec_config_reset(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { - let database = args.get_one::("database").unwrap(); - - let response = request( - config, - &serde_json::json!({ - "action": "config.reset", - "database": database, - }), - ) - .await?; - - let response_text = response.text().await?; - println!("{response_text}"); +#[cfg(test)] +mod tests { + use super::*; - Ok(()) -} - -async fn exec_idp(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { - let (cmd, subcommand_args) = args.subcommand().expect("Subcommand required"); - match cmd { - "set" => exec_idp_set(config, subcommand_args).await, - "enable" => exec_idp_toggle(config, subcommand_args, true).await, - "disable" => exec_idp_toggle(config, subcommand_args, false).await, - unknown => Err(anyhow::anyhow!("Invalid subcommand: {unknown}")), + #[test] + fn bool_settings() { + for value in ["true", "FALSE", "1", "0"] { + assert!(validate_config_setting(&AuthConfigSetting::LoginEmail, value).is_ok()); + assert!(validate_client_setting(&ClientSetting::Private, value).is_ok()); + } + for value in ["yes", "", "2"] { + assert!(validate_config_setting(&AuthConfigSetting::LoginAnonymous, value).is_err()); + assert!(validate_client_setting(&ClientSetting::Web, value).is_err()); + } } -} - -async fn exec_idp_set(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { - let database = args.get_one::("database").unwrap(); - let idp = args.get_one::("idp").unwrap(); - let client_id = args.get_one::("client_id").unwrap(); - let client_secret = args.get_one::("client_secret").unwrap(); - - let response = request( - config, - &serde_json::json!({ - "action": "idp.set", - "database": database, - "idp": idp.to_string(), - "client_id": client_id, - "client_secret": client_secret, - }), - ) - .await?; - - let response_text = response.text().await?; - println!("{response_text}"); - - Ok(()) -} - -async fn exec_idp_toggle(config: Config, args: &ArgMatches, enabled: bool) -> Result<(), anyhow::Error> { - let database = args.get_one::("database").unwrap(); - let idp = args.get_one::("idp").unwrap(); - let response = request( - config, - &serde_json::json!({ - "action": "idp.toggle", - "database": database, - "idp": idp.to_string(), - "enabled": enabled, - }), - ) - .await?; - - let response_text = response.text().await?; - println!("{response_text}"); - - Ok(()) -} + #[test] + fn steam_app_ids() { + assert!(validate_config_setting(&AuthConfigSetting::SteamAppIds, "480, 730").is_ok()); + assert!(validate_config_setting(&AuthConfigSetting::SteamAppIds, "480,abc").is_err()); + assert!(validate_config_setting(&AuthConfigSetting::SteamAppIds, "480,,730").is_err()); + assert!(validate_config_setting(&AuthConfigSetting::SteamAppIds, "-1").is_err()); + } -fn validate_value(setting: &AuthConfigSetting, value: &str) -> Result<(), anyhow::Error> { - match setting { - AuthConfigSetting::ColorText - | AuthConfigSetting::ColorBackground - | AuthConfigSetting::ColorPrimary - | AuthConfigSetting::ColorInput - | AuthConfigSetting::ColorBorder => { - anyhow::ensure!(is_valid_css_color(value), "invalid CSS color: {value:?}"); - } - AuthConfigSetting::FaviconUrl => { - url::Url::parse(value).map_err(|e| anyhow::anyhow!("invalid URL: {e}"))?; - } - AuthConfigSetting::LoginEmail | AuthConfigSetting::LoginAnonymous => { - anyhow::ensure!( - matches!(value.to_lowercase().as_str(), "true" | "false" | "1" | "0"), - "expected a boolean (true/false), got: {value:?}" - ); - } - AuthConfigSetting::SteamAppIds => { - for part in value.split(',') { - let part = part.trim(); - anyhow::ensure!(!part.is_empty(), "Steam app ID list must not contain empty entries"); - part.parse::() - .map_err(|_| anyhow::anyhow!("invalid Steam app ID: {part:?}, expected a positive integer"))?; - } - } - AuthConfigSetting::DisplayName | AuthConfigSetting::SteamPublisherKey => {} + #[test] + fn redirect_uris() { + let key = ClientSetting::RedirectUris; + assert!(validate_client_setting(&key, "https://example.com/cb, myapp://callback").is_ok()); + assert!(validate_client_setting(&key, "not a uri").is_err()); + assert!(validate_client_setting(&key, "https://example.com/cb,").is_err()); } - Ok(()) -} -fn is_valid_css_color(value: &str) -> bool { - const NAMED: &[&str] = &[ - "aliceblue", - "antiquewhite", - "aqua", - "aquamarine", - "azure", - "beige", - "bisque", - "black", - "blanchedalmond", - "blue", - "blueviolet", - "brown", - "burlywood", - "cadetblue", - "chartreuse", - "chocolate", - "coral", - "cornflowerblue", - "cornsilk", - "crimson", - "cyan", - "darkblue", - "darkcyan", - "darkgoldenrod", - "darkgray", - "darkgreen", - "darkgrey", - "darkkhaki", - "darkmagenta", - "darkolivegreen", - "darkorange", - "darkorchid", - "darkred", - "darksalmon", - "darkseagreen", - "darkslateblue", - "darkslategray", - "darkslategrey", - "darkturquoise", - "darkviolet", - "deeppink", - "deepskyblue", - "dimgray", - "dimgrey", - "dodgerblue", - "firebrick", - "floralwhite", - "forestgreen", - "fuchsia", - "gainsboro", - "ghostwhite", - "gold", - "goldenrod", - "gray", - "green", - "greenyellow", - "grey", - "honeydew", - "hotpink", - "indianred", - "indigo", - "ivory", - "khaki", - "lavender", - "lavenderblush", - "lawngreen", - "lemonchiffon", - "lightblue", - "lightcoral", - "lightcyan", - "lightgoldenrodyellow", - "lightgray", - "lightgreen", - "lightgrey", - "lightpink", - "lightsalmon", - "lightseagreen", - "lightskyblue", - "lightslategray", - "lightslategrey", - "lightsteelblue", - "lightyellow", - "lime", - "limegreen", - "linen", - "magenta", - "maroon", - "mediumaquamarine", - "mediumblue", - "mediumorchid", - "mediumpurple", - "mediumseagreen", - "mediumslateblue", - "mediumspringgreen", - "mediumturquoise", - "mediumvioletred", - "midnightblue", - "mintcream", - "mistyrose", - "moccasin", - "navajowhite", - "navy", - "oldlace", - "olive", - "olivedrab", - "orange", - "orangered", - "orchid", - "palegoldenrod", - "palegreen", - "paleturquoise", - "palevioletred", - "papayawhip", - "peachpuff", - "peru", - "pink", - "plum", - "powderblue", - "purple", - "rebeccapurple", - "red", - "rosybrown", - "royalblue", - "saddlebrown", - "salmon", - "sandybrown", - "seagreen", - "seashell", - "sienna", - "silver", - "skyblue", - "slateblue", - "slategray", - "slategrey", - "snow", - "springgreen", - "steelblue", - "tan", - "teal", - "thistle", - "tomato", - "transparent", - "turquoise", - "violet", - "wheat", - "white", - "whitesmoke", - "yellow", - "yellowgreen", - ]; - - let lower = value.to_lowercase(); - - if NAMED.contains(&lower.as_str()) { - return true; + #[test] + fn empty_list_clears() { + assert!(validate_config_setting(&AuthConfigSetting::SteamAppIds, "").is_ok()); + assert!(validate_client_setting(&ClientSetting::RedirectUris, " ").is_ok()); + assert!(validate_client_setting(&ClientSetting::PostLogoutRedirectUris, "").is_ok()); } - if let Some(hex) = lower.strip_prefix('#') { - let n = hex.len(); - return (n == 3 || n == 4 || n == 6 || n == 8) && hex.chars().all(|c| c.is_ascii_hexdigit()); + #[test] + fn client_name_not_empty() { + assert!(validate_client_setting(&ClientSetting::Name, "My Client").is_ok()); + assert!(validate_client_setting(&ClientSetting::Name, " ").is_err()); } - // rgb(), rgba(), hsl(), hsla() - let func_re = - regex::Regex::new(r"(?i)^(rgba?|hsla?)\(\s*[\d.]+%?\s*,\s*[\d.]+%?\s*,\s*[\d.]+%?\s*(?:,\s*[\d.]+\s*)?\)$") + #[test] + fn cli_parses() { + cli().debug_assert(); + let matches = cli() + .try_get_matches_from([ + "auth", + "client", + "set", + "my-db", + "redirect_uris", + "https://a.b", + "--name", + "c", + ]) .unwrap(); - func_re.is_match(value) + let (_, client) = matches.subcommand().unwrap(); + let (_, set) = client.subcommand().unwrap(); + assert_eq!(set.get_one::("database").unwrap(), "my-db"); + assert_eq!(set.get_one::("name").unwrap(), "c"); + assert!(matches!( + set.get_one::("key").unwrap(), + ClientSetting::RedirectUris + )); + } } diff --git a/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md b/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md index 3cc1b80f5b7..a83b4ab9ddf 100644 --- a/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md +++ b/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md @@ -815,14 +815,13 @@ Run `spacetime help unlock` for more detailed information. Manage SpacetimeAuth for a database -**Usage:** `spacetime auth - auth \` +**Usage:** `spacetime auth ` ###### **Subcommands:** * `config` — Manage SpacetimeAuth configuration for a database * `idp` — Manage identity providers for a database -* `client` — Manage OAuth clients for SpacetimeAuth +* `client` — Manage OAuth clients for a database @@ -847,7 +846,7 @@ Set a SpacetimeAuth configuration value for a database ###### **Arguments:** -* `` — The name or identity of the database +* `` — The name of the database * `` — The setting to configure Possible values: `display_name`, `favicon_url`, `color.text`, `color.background`, `color.primary`, `color.input`, `color.border`, `login.email`, `login.anonymous`, `steam.publisher_key`, `steam.app_ids` @@ -864,7 +863,7 @@ Reset all SpacetimeAuth configuration for a database ###### **Arguments:** -* `` — The name or identity of the database +* `` — The name of the database @@ -890,7 +889,7 @@ Configure an identity provider for a database ###### **Arguments:** -* `` — The name or identity of the database +* `` — The name of the database * `` — The identity provider to configure Possible values: `google`, `twitch`, `discord`, `kick`, `github`, `trackmania` @@ -908,7 +907,7 @@ Enable an identity provider for a database ###### **Arguments:** -* `` — The name or identity of the database +* `` — The name of the database * `` — The identity provider to configure Possible values: `google`, `twitch`, `discord`, `kick`, `github`, `trackmania` @@ -924,7 +923,7 @@ Disable an identity provider for a database ###### **Arguments:** -* `` — The name or identity of the database +* `` — The name of the database * `` — The identity provider to configure Possible values: `google`, `twitch`, `discord`, `kick`, `github`, `trackmania` @@ -934,7 +933,7 @@ Disable an identity provider for a database ## `spacetime auth client` -Manage OAuth clients for SpacetimeAuth +Manage OAuth clients for a database **Usage:** `spacetime auth client ` @@ -951,16 +950,17 @@ Manage OAuth clients for SpacetimeAuth Create a new OAuth client -**Usage:** `spacetime auth client create [OPTIONS] [name]` +**Usage:** `spacetime auth client create [OPTIONS] ` ###### **Arguments:** -* `` — The client name - - Default value: `Default Client` +* `` — The name of the database ###### **Options:** +* `--name ` — The client name + + Default value: `Default Client` * `--private` — Create the client as private (requires a client secret for token exchange) @@ -969,11 +969,15 @@ Create a new OAuth client Delete an OAuth client -**Usage:** `spacetime auth client delete [name]` +**Usage:** `spacetime auth client delete [OPTIONS] ` ###### **Arguments:** -* `` — The client name +* `` — The name of the database + +###### **Options:** + +* `--name ` — The client name Default value: `Default Client` @@ -983,16 +987,17 @@ Delete an OAuth client Get an OAuth client -**Usage:** `spacetime auth client get [OPTIONS] [name]` +**Usage:** `spacetime auth client get [OPTIONS] ` ###### **Arguments:** -* `` — The client name - - Default value: `Default Client` +* `` — The name of the database ###### **Options:** +* `--name ` — The client name + + Default value: `Default Client` * `--include-secret` — Include the client secret in the output @@ -1001,16 +1006,22 @@ Get an OAuth client Set a configuration value for an OAuth client -**Usage:** `spacetime auth client set ...` - -ARGS: - [name] Client name (default: "Default Client") - Setting to update: name, private, web, native, redirect_uris, post_logout_redirect_uris - Value to assign +**Usage:** `spacetime auth client set [OPTIONS] ` ###### **Arguments:** -* `` — [name] +* `` — The name of the database +* `` — The setting to configure + + Possible values: `name`, `private`, `web`, `native`, `redirect_uris`, `post_logout_redirect_uris` + +* `` — The value to assign to the setting + +###### **Options:** + +* `--name ` — The client name + + Default value: `Default Client` From 9a25e65b7fb69d69b31aadf69487940cc6dc323f Mon Sep 17 00:00:00 2001 From: Julien Lavocat Date: Thu, 1 Oct 2026 14:09:41 +0200 Subject: [PATCH 3/5] Hint at how to retrieve a client secret in spacetime auth The SpacetimeAuth management API no longer returns client secrets except from `client get --include-secret`. After `client create --private` and `client set private true`, print the command that retrieves the secret. The hint goes to stderr so stdout stays the JSON response. --- crates/cli/src/subcommands/auth.rs | 47 +++++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/crates/cli/src/subcommands/auth.rs b/crates/cli/src/subcommands/auth.rs index 972cb4a63a3..43b3d550b31 100644 --- a/crates/cli/src/subcommands/auth.rs +++ b/crates/cli/src/subcommands/auth.rs @@ -107,6 +107,14 @@ async fn send(config: &Config, body: serde_json::Value) -> anyhow::Result<()> { Ok(()) } +/// The API never echoes a client secret back, so point at the command that does. +fn secret_hint(database: &str, name: &str) -> String { + format!( + "The client secret is not shown. To retrieve it, run:\n \ + spacetime auth client get {database} --name {name:?} --include-secret" + ) +} + fn database_arg() -> Arg { Arg::new("database").required(true).help("The name of the database") } @@ -238,6 +246,7 @@ pub async fn exec(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error let (cmd, args) = args.subcommand().expect("Subcommand required"); let (subcmd, args) = args.subcommand().expect("Subcommand required"); let database = args.get_one::("database").unwrap(); + let mut hint = None; let body = match (cmd, subcmd) { ("config", "set") => { @@ -268,12 +277,19 @@ pub async fn exec(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error "idp": args.get_one::("idp").unwrap().to_string(), "enabled": toggle == "enable", }), - ("client", "create") => serde_json::json!({ - "action": "client.create", - "database": database, - "name": args.get_one::("name").unwrap(), - "private": args.get_flag("private"), - }), + ("client", "create") => { + let name = args.get_one::("name").unwrap(); + let private = args.get_flag("private"); + if private { + hint = Some(secret_hint(database, name)); + } + serde_json::json!({ + "action": "client.create", + "database": database, + "name": name, + "private": private, + }) + } ("client", "delete") => serde_json::json!({ "action": "client.delete", "database": database, @@ -289,10 +305,14 @@ pub async fn exec(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error let key = args.get_one::("key").unwrap(); let value = args.get_one::("value").unwrap(); validate_client_setting(key, value)?; + let name = args.get_one::("name").unwrap(); + if matches!(key, ClientSetting::Private) && matches!(value.to_lowercase().as_str(), "true" | "1") { + hint = Some(secret_hint(database, name)); + } serde_json::json!({ "action": "client.set", "database": database, - "name": args.get_one::("name").unwrap(), + "name": name, "key": key.to_string(), "value": value, }) @@ -300,7 +320,12 @@ pub async fn exec(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error (cmd, subcmd) => anyhow::bail!("Invalid subcommand: {cmd} {subcmd}"), }; - send(&config, body).await + send(&config, body).await?; + // On stderr, so stdout stays the JSON response. + if let Some(hint) = hint { + eprintln!("\n{hint}"); + } + Ok(()) } fn validate_bool(value: &str) -> anyhow::Result<()> { @@ -404,6 +429,12 @@ mod tests { assert!(validate_client_setting(&ClientSetting::Name, " ").is_err()); } + #[test] + fn secret_hint_quotes_the_client_name() { + assert!(secret_hint("my-db", "Default Client") + .ends_with("spacetime auth client get my-db --name \"Default Client\" --include-secret")); + } + #[test] fn cli_parses() { cli().debug_assert(); From 906d8fb694858d308dff452f9840f4a2258c07a4 Mon Sep 17 00:00:00 2001 From: Julien Lavocat Date: Thu, 1 Oct 2026 14:16:06 +0200 Subject: [PATCH 4/5] Correct spacetime auth help texts - `config reset` keeps the display name and Steam settings; it does not reset everything. - `idp set` also enables the provider. - The database argument is a name; identities are rejected. - Document comma-separated lists and that an empty value clears them. --- crates/cli/src/subcommands/auth.rs | 12 ++++---- .../00100-cli-reference.md | 30 +++++++++---------- 2 files changed, 22 insertions(+), 20 deletions(-) diff --git a/crates/cli/src/subcommands/auth.rs b/crates/cli/src/subcommands/auth.rs index 43b3d550b31..132f042d5b4 100644 --- a/crates/cli/src/subcommands/auth.rs +++ b/crates/cli/src/subcommands/auth.rs @@ -116,7 +116,9 @@ fn secret_hint(database: &str, name: &str) -> String { } fn database_arg() -> Arg { - Arg::new("database").required(true).help("The name of the database") + Arg::new("database") + .required(true) + .help("The name of the database (not its identity)") } fn idp_arg() -> Arg { @@ -158,12 +160,12 @@ fn get_subcommands() -> Vec { .arg( Arg::new("value") .required(true) - .help("The value to assign to the setting"), + .help("The value to assign to the setting. `steam.app_ids` takes a comma-separated list; an empty value clears a Steam setting"), ), ) .subcommand( Command::new("reset") - .about("Reset all SpacetimeAuth configuration for a database") + .about("Reset the SpacetimeAuth configuration of a database to its defaults, keeping the display name and Steam settings") .arg(database_arg()), ), Command::new("idp") @@ -171,7 +173,7 @@ fn get_subcommands() -> Vec { .subcommand_required(true) .subcommand( Command::new("set") - .about("Configure an identity provider for a database") + .about("Configure and enable an identity provider for a database") .arg(database_arg()) .arg(idp_arg()) .arg(Arg::new("client_id").required(true).help("The OAuth client ID")) @@ -235,7 +237,7 @@ fn get_subcommands() -> Vec { .arg( Arg::new("value") .required(true) - .help("The value to assign to the setting"), + .help("The value to assign to the setting. URI lists are comma-separated; an empty value clears the list"), ) .arg(client_name_arg()), ), diff --git a/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md b/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md index a83b4ab9ddf..980a10289ef 100644 --- a/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md +++ b/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md @@ -834,7 +834,7 @@ Manage SpacetimeAuth configuration for a database ###### **Subcommands:** * `set` — Set a SpacetimeAuth configuration value for a database -* `reset` — Reset all SpacetimeAuth configuration for a database +* `reset` — Reset the SpacetimeAuth configuration of a database to its defaults, keeping the display name and Steam settings @@ -846,24 +846,24 @@ Set a SpacetimeAuth configuration value for a database ###### **Arguments:** -* `` — The name of the database +* `` — The name of the database (not its identity) * `` — The setting to configure Possible values: `display_name`, `favicon_url`, `color.text`, `color.background`, `color.primary`, `color.input`, `color.border`, `login.email`, `login.anonymous`, `steam.publisher_key`, `steam.app_ids` -* `` — The value to assign to the setting +* `` — The value to assign to the setting. `steam.app_ids` takes a comma-separated list; an empty value clears a Steam setting ## `spacetime auth config reset` -Reset all SpacetimeAuth configuration for a database +Reset the SpacetimeAuth configuration of a database to its defaults, keeping the display name and Steam settings **Usage:** `spacetime auth config reset ` ###### **Arguments:** -* `` — The name of the database +* `` — The name of the database (not its identity) @@ -875,7 +875,7 @@ Manage identity providers for a database ###### **Subcommands:** -* `set` — Configure an identity provider for a database +* `set` — Configure and enable an identity provider for a database * `enable` — Enable an identity provider for a database * `disable` — Disable an identity provider for a database @@ -883,13 +883,13 @@ Manage identity providers for a database ## `spacetime auth idp set` -Configure an identity provider for a database +Configure and enable an identity provider for a database **Usage:** `spacetime auth idp set ` ###### **Arguments:** -* `` — The name of the database +* `` — The name of the database (not its identity) * `` — The identity provider to configure Possible values: `google`, `twitch`, `discord`, `kick`, `github`, `trackmania` @@ -907,7 +907,7 @@ Enable an identity provider for a database ###### **Arguments:** -* `` — The name of the database +* `` — The name of the database (not its identity) * `` — The identity provider to configure Possible values: `google`, `twitch`, `discord`, `kick`, `github`, `trackmania` @@ -923,7 +923,7 @@ Disable an identity provider for a database ###### **Arguments:** -* `` — The name of the database +* `` — The name of the database (not its identity) * `` — The identity provider to configure Possible values: `google`, `twitch`, `discord`, `kick`, `github`, `trackmania` @@ -954,7 +954,7 @@ Create a new OAuth client ###### **Arguments:** -* `` — The name of the database +* `` — The name of the database (not its identity) ###### **Options:** @@ -973,7 +973,7 @@ Delete an OAuth client ###### **Arguments:** -* `` — The name of the database +* `` — The name of the database (not its identity) ###### **Options:** @@ -991,7 +991,7 @@ Get an OAuth client ###### **Arguments:** -* `` — The name of the database +* `` — The name of the database (not its identity) ###### **Options:** @@ -1010,12 +1010,12 @@ Set a configuration value for an OAuth client ###### **Arguments:** -* `` — The name of the database +* `` — The name of the database (not its identity) * `` — The setting to configure Possible values: `name`, `private`, `web`, `native`, `redirect_uris`, `post_logout_redirect_uris` -* `` — The value to assign to the setting +* `` — The value to assign to the setting. URI lists are comma-separated; an empty value clears the list ###### **Options:** From 393dd3855398033642301b0d20724450ea93cdb2 Mon Sep 17 00:00:00 2001 From: Julien Lavocat Date: Thu, 8 Oct 2026 17:28:37 +0200 Subject: [PATCH 5/5] Prompt for the identity provider client secret in spacetime auth Passing the secret as an argument saves it to shell history. The argument is now optional: when omitted, the secret is prompted for with hidden input, or read from stdin when piped. --- Cargo.lock | 1 + crates/cli/Cargo.toml | 2 +- crates/cli/src/subcommands/auth.rs | 40 +++++++++++++++---- .../00100-cli-reference.md | 4 +- 4 files changed, 36 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d0b0eb81a0e..44234bec228 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2093,6 +2093,7 @@ dependencies = [ "fuzzy-matcher", "shell-words", "thiserror 1.0.69", + "zeroize", ] [[package]] diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 42f01b8dd6d..2f6bd43038f 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -74,7 +74,7 @@ webbrowser.workspace = true clap-markdown.workspace = true git2.workspace = true glob.workspace = true -dialoguer = { workspace = true, features = ["fuzzy-select"] } +dialoguer = { workspace = true, features = ["fuzzy-select", "password"] } rolldown.workspace = true rolldown_common.workspace = true rolldown_error.workspace = true diff --git a/crates/cli/src/subcommands/auth.rs b/crates/cli/src/subcommands/auth.rs index 132f042d5b4..612e6e4bf20 100644 --- a/crates/cli/src/subcommands/auth.rs +++ b/crates/cli/src/subcommands/auth.rs @@ -1,7 +1,9 @@ use crate::login::DEFAULT_AUTH_HOST; use crate::Config; use clap::{Arg, ArgMatches, Command, ValueEnum}; +use is_terminal::IsTerminal; use reqwest::Client; +use std::io::Read; #[derive(Clone, Debug, ValueEnum)] pub enum IdentityProvider { @@ -115,6 +117,20 @@ fn secret_hint(database: &str, name: &str) -> String { ) } +fn read_client_secret() -> anyhow::Result { + let secret = if std::io::stdin().is_terminal() { + dialoguer::Password::new() + .with_prompt("OAuth client secret") + .interact()? + } else { + let mut input = String::new(); + std::io::stdin().read_to_string(&mut input)?; + input.trim_end_matches(['\r', '\n']).to_owned() + }; + anyhow::ensure!(!secret.is_empty(), "the client secret cannot be empty"); + Ok(secret) +} + fn database_arg() -> Arg { Arg::new("database") .required(true) @@ -177,7 +193,9 @@ fn get_subcommands() -> Vec { .arg(database_arg()) .arg(idp_arg()) .arg(Arg::new("client_id").required(true).help("The OAuth client ID")) - .arg(Arg::new("client_secret").required(true).help("The OAuth client secret")), + .arg(Arg::new("client_secret").help( + "The OAuth client secret. If omitted, it is prompted for, or read from stdin when piped", + )), ) .subcommand( Command::new("enable") @@ -266,13 +284,19 @@ pub async fn exec(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error "action": "config.reset", "database": database, }), - ("idp", "set") => serde_json::json!({ - "action": "idp.set", - "database": database, - "idp": args.get_one::("idp").unwrap().to_string(), - "client_id": args.get_one::("client_id").unwrap(), - "client_secret": args.get_one::("client_secret").unwrap(), - }), + ("idp", "set") => { + let client_secret = match args.get_one::("client_secret") { + Some(secret) => secret.clone(), + None => read_client_secret()?, + }; + serde_json::json!({ + "action": "idp.set", + "database": database, + "idp": args.get_one::("idp").unwrap().to_string(), + "client_id": args.get_one::("client_id").unwrap(), + "client_secret": client_secret, + }) + } ("idp", toggle @ ("enable" | "disable")) => serde_json::json!({ "action": "idp.toggle", "database": database, diff --git a/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md b/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md index 980a10289ef..0266e5f203c 100644 --- a/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md +++ b/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md @@ -885,7 +885,7 @@ Manage identity providers for a database Configure and enable an identity provider for a database -**Usage:** `spacetime auth idp set ` +**Usage:** `spacetime auth idp set [client_secret]` ###### **Arguments:** @@ -895,7 +895,7 @@ Configure and enable an identity provider for a database Possible values: `google`, `twitch`, `discord`, `kick`, `github`, `trackmania` * `` — The OAuth client ID -* `` — The OAuth client secret +* `` — The OAuth client secret. If omitted, it is prompted for, or read from stdin when piped