diff --git a/Cargo.lock b/Cargo.lock index ef5953bbd2e..ed1274f313c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7858,6 +7858,7 @@ dependencies = [ "toml 0.8.23", "toml_edit 0.22.27", "tracing", + "url", "walkdir", "wasmbin", "webbrowser", diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index bb71fb8b6e5..4a02f8ae6ac 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -73,6 +73,7 @@ tokio-tungstenite.workspace = true toml.workspace = true toml_edit.workspace = true tracing = { workspace = true, features = ["release_max_level_off"] } +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 c44b3d2a41e..4e830b78337 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -38,6 +38,7 @@ pub fn get_subcommands() -> Vec { server::cli(), subscribe::cli(), start::cli(), + auth::cli(), subcommands::version::cli(), ] } @@ -67,6 +68,7 @@ pub async fn exec_subcommand( "start" => return start::exec(config, paths, args).await, "login" => login::exec(config, args).await, "logout" => logout::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..5de9e124c41 --- /dev/null +++ b/crates/cli/src/subcommands/auth.rs @@ -0,0 +1,567 @@ +use crate::common_args; +use crate::Config; +use clap::{Arg, ArgMatches, Command, ValueEnum}; +use spacetimedb_paths::SpacetimePaths; + +#[derive(Clone, Debug, ValueEnum)] +pub enum IdentityProvider { + Google, + Twitch, + Discord, + Kick, + Github, + Trackmania, +} + +#[derive(Clone, Debug, ValueEnum)] +pub enum ClientSetting { + Name, + Private, + Web, + Native, + #[value(name = "redirect_uris")] + RedirectUris, + #[value(name = "post_logout_redirect_uris")] + PostLogoutRedirectUris, +} + +const DEFAULT_CLIENT_NAME: &str = "Default Client"; + +#[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, +} + +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"), + ) + .arg( + common_args::server().help("The nickname, host name or URL of the server hosting the database"), + ), + ) + .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( + common_args::server().help("The nickname, host name or URL of the server hosting 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")) + .arg( + common_args::server().help("The nickname, host name or URL of the server hosting the database"), + ), + ) + .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(common_args::server().help("The nickname, host name or URL of the server")), + ) + .subcommand( + Command::new("delete") + .about("Delete an OAuth client") + .arg( + Arg::new("name") + .required(false) + .default_value(DEFAULT_CLIENT_NAME) + .help("The client name"), + ) + .arg(common_args::server().help("The nickname, host name or URL of the server")), + ) + .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"), + ) + .arg(common_args::server().help("The nickname, host name or URL of the server")), + ) + .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] "), + ) + .arg(common_args::server().help("The nickname, host name or URL of the server")), + ), + ] +} + +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"), + ) + .arg(common_args::server().help("The nickname, host name or URL of the server hosting the database")) +} + +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(mut _config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { + let _name = args.get_one::("name").unwrap(); + let _server = args.get_one::("server").map(|s| s.as_str()); + + todo!("implement auth client create") +} + +async fn exec_client_delete(mut _config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { + let _name = args.get_one::("name").unwrap(); + let _server = args.get_one::("server").map(|s| s.as_str()); + + todo!("implement auth client delete") +} + +async fn exec_client_get(mut _config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { + let _name = args.get_one::("name").unwrap(); + let _include_secret = args.get_flag("include-secret"); + let _server = args.get_one::("server").map(|s| s.as_str()); + + todo!("implement auth client get") +} + +async fn exec_client_set(mut _config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { + let raw: Vec<&String> = args.get_many::("args").unwrap().collect(); + let _server = args.get_one::("server").map(|s| s.as_str()); + + // 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 (_client_name, _key, _value) = (client_name, key, value_str); + todo!("implement auth client set") +} + +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(mut _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(); + let _server = args.get_one::("server").map(|s| s.as_str()); + + validate_value(key, value)?; + + todo!("implement auth config set") +} + +async fn exec_config_reset(mut _config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { + let _database = args.get_one::("database").unwrap(); + let _server = args.get_one::("server").map(|s| s.as_str()); + + todo!("implement auth config reset") +} + +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(mut _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 _server = args.get_one::("server").map(|s| s.as_str()); + + todo!("implement auth idp set") +} + +async fn exec_idp_toggle(mut _config: Config, args: &ArgMatches, _enabled: bool) -> Result<(), anyhow::Error> { + let _database = args.get_one::("database").unwrap(); + let _idp = args.get_one::("idp").unwrap(); + let _server = args.get_one::("server").map(|s| s.as_str()); + + todo!("implement auth idp enable/disable") +} + +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() — comma-separated legacy syntax + 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 58456274469..ccea8217721 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;