diff --git a/.env.example b/.env.example index ed74e98..b8ecc0e 100644 --- a/.env.example +++ b/.env.example @@ -4,5 +4,15 @@ KFRAME_TCP_ADDR=0.0.0.0:2699 KFRAME_HTTP_ADDR=0.0.0.0:3000 KFRAME_POLL_INTERVAL_SECS=5 +# Auth (required) +JWT_SECRET=change-me-to-a-random-secret +JWT_TTL_SECONDS=3600 + +# Encryption at rest (required, generate with: openssl rand -hex 32) +KFRAME_ENCRYPTION_KEY=change-me-generate-with-openssl-rand-hex-32 + +# SPA static files (optional, omit for dev mode with Vite proxy) +# KFRAME_SPA_DIR=spa/dist + # Logging (tracing-subscriber) RUST_LOG=info,sqlx=warn diff --git a/Cargo.lock b/Cargo.lock index 419c76d..7aba58b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,41 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -40,6 +75,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + [[package]] name = "atoi" version = "2.0.0" @@ -146,6 +193,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -166,8 +222,10 @@ dependencies = [ "dotenvy", "http-api", "http-json", + "kframe-auth", "media-adapter", "rss-adapter", + "secret-store", "tcp-server", "tokio", "tracing", @@ -208,6 +266,16 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "client-application" version = "0.1.0" @@ -352,9 +420,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core", "typenum", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "der" version = "0.7.10" @@ -366,6 +444,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "digest" version = "0.10.7" @@ -621,8 +705,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -636,6 +722,16 @@ dependencies = [ "r-efi", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "h2" version = "0.4.15" @@ -991,6 +1087,15 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -1014,6 +1119,32 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + +[[package]] +name = "kframe-auth" +version = "0.1.0" +dependencies = [ + "argon2", + "domain", + "jsonwebtoken", + "rand_core", + "serde", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1189,6 +1320,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-bigint-dig" version = "0.8.6" @@ -1205,6 +1346,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-integer" version = "0.1.46" @@ -1241,6 +1388,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openssl" version = "0.10.81" @@ -1313,6 +1466,27 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -1367,6 +1541,18 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "postcard" version = "1.1.3" @@ -1388,6 +1574,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1666,6 +1858,17 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "secret-store" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "base64", + "domain", + "hex", + "rand_core", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -1802,6 +2005,18 @@ dependencies = [ "rand_core", ] +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + [[package]] name = "slab" version = "0.4.12" @@ -2174,6 +2389,36 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -2431,6 +2676,16 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index acf4c50..b3f97e4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,8 @@ members = [ "crates/adapters/http-json", "crates/adapters/rss", "crates/adapters/media", + "crates/adapters/auth", + "crates/adapters/secret-store", "crates/api-types", "crates/bootstrap", "crates/client-desktop", @@ -38,6 +40,8 @@ http-json = { path = "crates/adapters/http-json" } http-api = { path = "crates/adapters/http-api" } media-adapter = { path = "crates/adapters/media" } rss-adapter = { path = "crates/adapters/rss" } +kframe-auth = { path = "crates/adapters/auth" } +secret-store = { path = "crates/adapters/secret-store" } axum = { version = "0.8", features = ["macros"] } tower-http = { version = "0.6", features = ["cors", "fs"] } api-types = { path = "crates/api-types" } diff --git a/crates/adapters/auth/Cargo.toml b/crates/adapters/auth/Cargo.toml new file mode 100644 index 0000000..3fc5ba5 --- /dev/null +++ b/crates/adapters/auth/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "kframe-auth" +version = "0.1.0" +edition = "2024" + +[dependencies] +domain.workspace = true +jsonwebtoken = "9" +argon2 = { version = "0.5", features = ["std"] } +rand_core = { version = "0.6", features = ["getrandom"] } +serde = { version = "1", features = ["derive"] } diff --git a/crates/adapters/auth/src/lib.rs b/crates/adapters/auth/src/lib.rs new file mode 100644 index 0000000..b4e01e9 --- /dev/null +++ b/crates/adapters/auth/src/lib.rs @@ -0,0 +1,90 @@ +use argon2::{ + Argon2, + password_hash::{PasswordHasher, PasswordVerifier, SaltString}, +}; +use domain::{AuthPort, PasswordHashPort, UserId}; +use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode}; +use rand_core::OsRng; +use serde::{Deserialize, Serialize}; + +pub struct AuthConfig { + pub secret: String, + pub ttl_seconds: u64, +} + +impl AuthConfig { + pub fn from_env() -> Result { + let secret = std::env::var("JWT_SECRET") + .map_err(|_| "JWT_SECRET env var is required".to_string())?; + if secret.is_empty() { + return Err("JWT_SECRET must not be empty".into()); + } + let ttl_seconds = std::env::var("JWT_TTL_SECONDS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(3600u64); + Ok(Self { + secret, + ttl_seconds, + }) + } +} + +#[derive(Serialize, Deserialize)] +struct Claims { + sub: u32, + exp: u64, +} + +pub struct JwtAuthService { + config: AuthConfig, +} + +impl JwtAuthService { + pub fn new(config: AuthConfig) -> Self { + Self { config } + } +} + +impl AuthPort for JwtAuthService { + fn generate_token(&self, user_id: UserId) -> String { + let exp = jsonwebtoken::get_current_timestamp() + self.config.ttl_seconds; + let claims = Claims { sub: user_id, exp }; + encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(self.config.secret.as_bytes()), + ) + .expect("JWT encoding should not fail") + } + + fn validate_token(&self, token: &str) -> Option { + let data = decode::( + token, + &DecodingKey::from_secret(self.config.secret.as_bytes()), + &Validation::default(), + ) + .ok()?; + Some(data.claims.sub) + } +} + +pub struct Argon2Hasher; + +impl PasswordHashPort for Argon2Hasher { + async fn hash(&self, plain: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + let hash = Argon2::default() + .hash_password(plain.as_bytes(), &salt) + .map_err(|e| e.to_string())? + .to_string(); + Ok(hash) + } + + async fn verify(&self, plain: &str, hash: &str) -> Result { + let parsed = argon2::password_hash::PasswordHash::new(hash).map_err(|e| e.to_string())?; + Ok(Argon2::default() + .verify_password(plain.as_bytes(), &parsed) + .is_ok()) + } +} diff --git a/crates/adapters/config-memory/src/lib.rs b/crates/adapters/config-memory/src/lib.rs index 9c1191b..53735bb 100644 --- a/crates/adapters/config-memory/src/lib.rs +++ b/crates/adapters/config-memory/src/lib.rs @@ -1,6 +1,6 @@ use domain::{ - ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, WidgetConfig, - WidgetId, + ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, User, + WidgetConfig, WidgetId, }; use std::collections::HashMap; use std::sync::RwLock; @@ -16,6 +16,7 @@ pub struct MemoryConfigStore { data_sources: RwLock>, layout: RwLock>, presets: RwLock>, + users: RwLock>, } impl Default for MemoryConfigStore { @@ -25,6 +26,7 @@ impl Default for MemoryConfigStore { data_sources: RwLock::new(HashMap::new()), layout: RwLock::new(None), presets: RwLock::new(HashMap::new()), + users: RwLock::new(Vec::new()), } } } @@ -156,4 +158,30 @@ impl ConfigRepository for MemoryConfigStore { guard.remove(&id); Ok(()) } + + async fn get_user_by_username(&self, username: &str) -> Result, Self::Error> { + let guard = self + .users + .read() + .map_err(|_| MemoryConfigError::LockPoisoned)?; + Ok(guard.iter().find(|u| u.username == username).cloned()) + } + + async fn save_user(&self, user: &User) -> Result<(), Self::Error> { + let mut guard = self + .users + .write() + .map_err(|_| MemoryConfigError::LockPoisoned)?; + guard.retain(|u| u.id != user.id); + guard.push(user.clone()); + Ok(()) + } + + async fn count_users(&self) -> Result { + let guard = self + .users + .read() + .map_err(|_| MemoryConfigError::LockPoisoned)?; + Ok(guard.len() as u32) + } } diff --git a/crates/adapters/config-sqlite/src/lib.rs b/crates/adapters/config-sqlite/src/lib.rs index f0bc7e1..b2a88a2 100644 --- a/crates/adapters/config-sqlite/src/lib.rs +++ b/crates/adapters/config-sqlite/src/lib.rs @@ -2,22 +2,36 @@ pub mod error; mod repository; mod serialization; +use domain::SecretStore; use sqlx::SqlitePool; +use std::sync::Arc; pub use error::SqliteConfigError; pub struct SqliteConfigStore { pool: SqlitePool, + secrets: Option>, } impl SqliteConfigStore { pub async fn new(database_url: &str) -> Result { + Self::with_secrets(database_url, None).await + } + + pub async fn with_secrets( + database_url: &str, + secrets: Option>, + ) -> Result { let pool = SqlitePool::connect(database_url).await?; - let store = Self { pool }; + let store = Self { pool, secrets }; store.migrate().await?; Ok(store) } + pub(crate) fn secrets(&self) -> Option<&(dyn SecretStore + Send + Sync)> { + self.secrets.as_deref() + } + async fn migrate(&self) -> Result<(), sqlx::Error> { sqlx::query( "CREATE TABLE IF NOT EXISTS widgets ( @@ -63,6 +77,16 @@ impl SqliteConfigStore { .execute(&self.pool) .await?; + sqlx::query( + "CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL + )", + ) + .execute(&self.pool) + .await?; + Ok(()) } } diff --git a/crates/adapters/config-sqlite/src/repository/data_sources.rs b/crates/adapters/config-sqlite/src/repository/data_sources.rs index 3f45cf3..0a070c8 100644 --- a/crates/adapters/config-sqlite/src/repository/data_sources.rs +++ b/crates/adapters/config-sqlite/src/repository/data_sources.rs @@ -16,7 +16,7 @@ impl SqliteConfigStore { match row { None => Ok(None), - Some(row) => Ok(Some(ser::data_source_from_row(&row)?)), + Some(row) => Ok(Some(ser::data_source_from_row(&row, self.secrets())?)), } } @@ -28,19 +28,22 @@ impl SqliteConfigStore { .await .map_err(SqliteConfigError::Sql)?; - rows.iter().map(ser::data_source_from_row).collect() + let secrets = self.secrets(); + rows.iter() + .map(|r| ser::data_source_from_row(r, secrets)) + .collect() } pub(crate) async fn save_data_source_impl( &self, source: &DataSource, ) -> Result<(), SqliteConfigError> { - let config_json = ser::data_source_config_to_json(&source.config)?; + let config_json = ser::data_source_config_to_json(&source.config, self.secrets())?; let type_str = ser::data_source_type_to_str(&source.source_type); sqlx::query( "INSERT OR REPLACE INTO data_sources (id, name, source_type, poll_interval_secs, config) - VALUES (?, ?, ?, ?, ?)" + VALUES (?, ?, ?, ?, ?)", ) .bind(source.id as i64) .bind(&source.name) diff --git a/crates/adapters/config-sqlite/src/repository/mod.rs b/crates/adapters/config-sqlite/src/repository/mod.rs index 53982b4..f5eb7fa 100644 --- a/crates/adapters/config-sqlite/src/repository/mod.rs +++ b/crates/adapters/config-sqlite/src/repository/mod.rs @@ -1,13 +1,14 @@ mod data_sources; mod layout; mod presets; +mod users; mod widgets; use crate::SqliteConfigStore; use crate::error::SqliteConfigError; use domain::{ - ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, WidgetConfig, - WidgetId, + ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, User, + WidgetConfig, WidgetId, }; impl ConfigRepository for SqliteConfigStore { @@ -68,4 +69,16 @@ impl ConfigRepository for SqliteConfigStore { async fn delete_preset(&self, id: LayoutPresetId) -> Result<(), Self::Error> { self.delete_preset_impl(id).await } + + async fn get_user_by_username(&self, username: &str) -> Result, Self::Error> { + self.get_user_by_username_impl(username).await + } + + async fn save_user(&self, user: &User) -> Result<(), Self::Error> { + self.save_user_impl(user).await + } + + async fn count_users(&self) -> Result { + self.count_users_impl().await + } } diff --git a/crates/adapters/config-sqlite/src/repository/users.rs b/crates/adapters/config-sqlite/src/repository/users.rs new file mode 100644 index 0000000..4073f84 --- /dev/null +++ b/crates/adapters/config-sqlite/src/repository/users.rs @@ -0,0 +1,48 @@ +use crate::SqliteConfigStore; +use crate::error::SqliteConfigError; +use domain::User; +use sqlx::Row; + +impl SqliteConfigStore { + pub(crate) async fn get_user_by_username_impl( + &self, + username: &str, + ) -> Result, SqliteConfigError> { + let row = sqlx::query("SELECT id, username, password_hash FROM users WHERE username = ?") + .bind(username) + .fetch_optional(&self.pool) + .await + .map_err(SqliteConfigError::Sql)?; + + Ok(row.map(|r| { + let id: i64 = r.get("id"); + User { + id: id as u32, + username: r.get("username"), + password_hash: r.get("password_hash"), + } + })) + } + + pub(crate) async fn save_user_impl(&self, user: &User) -> Result<(), SqliteConfigError> { + sqlx::query( + "INSERT INTO users (username, password_hash) VALUES (?, ?) + ON CONFLICT(username) DO UPDATE SET password_hash = excluded.password_hash", + ) + .bind(&user.username) + .bind(&user.password_hash) + .execute(&self.pool) + .await + .map_err(SqliteConfigError::Sql)?; + Ok(()) + } + + pub(crate) async fn count_users_impl(&self) -> Result { + let row = sqlx::query("SELECT COUNT(*) as cnt FROM users") + .fetch_one(&self.pool) + .await + .map_err(SqliteConfigError::Sql)?; + let count: i64 = row.get("cnt"); + Ok(count as u32) + } +} diff --git a/crates/adapters/config-sqlite/src/serialization/data_source.rs b/crates/adapters/config-sqlite/src/serialization/data_source.rs index 5378c3b..3ac2b2b 100644 --- a/crates/adapters/config-sqlite/src/serialization/data_source.rs +++ b/crates/adapters/config-sqlite/src/serialization/data_source.rs @@ -1,9 +1,16 @@ use crate::error::SqliteConfigError; -use domain::{DataSource, DataSourceConfig, DataSourceType}; +use domain::{DataSource, DataSourceConfig, DataSourceType, SecretStore}; use sqlx::Row; use sqlx::sqlite::SqliteRow; use std::time::Duration; +const SENSITIVE_KEYS: &[&str] = &["password", "secret", "token", "api_key", "apikey"]; + +fn is_sensitive_key(key: &str) -> bool { + let lower = key.to_lowercase(); + SENSITIVE_KEYS.iter().any(|s| lower.contains(s)) +} + pub fn data_source_type_to_str(t: &DataSourceType) -> &'static str { match t { DataSourceType::Weather => "weather", @@ -27,27 +34,78 @@ fn data_source_type_from_str(s: &str) -> Result Result { +pub fn data_source_config_to_json( + config: &DataSourceConfig, + secrets: Option<&(dyn SecretStore + Send + Sync)>, +) -> Result { + let api_key = config.api_key.as_ref().map(|k| match secrets { + Some(s) => s.encrypt(k), + None => k.clone(), + }); + + let headers: Vec<(String, String)> = config + .headers + .iter() + .map(|(k, v)| { + let val = if is_sensitive_key(k) { + match secrets { + Some(s) => s.encrypt(v), + None => v.clone(), + } + } else { + v.clone() + }; + (k.clone(), val) + }) + .collect(); + let v = serde_json::json!({ "url": config.url, - "headers": config.headers, - "api_key": config.api_key, + "headers": headers, + "api_key": api_key, + "encrypted": secrets.is_some(), }); serde_json::to_string(&v).map_err(|e| SqliteConfigError::Serialization(e.to_string())) } -fn data_source_config_from_json(json: &str) -> Result { +fn data_source_config_from_json( + json: &str, + secrets: Option<&(dyn SecretStore + Send + Sync)>, +) -> Result { let v: serde_json::Value = serde_json::from_str(json).map_err(|e| SqliteConfigError::Serialization(e.to_string()))?; + let encrypted = v["encrypted"].as_bool().unwrap_or(false); + let url = v["url"].as_str().map(String::from); - let api_key = v["api_key"].as_str().map(String::from); + + let api_key = v["api_key"].as_str().map(|k| { + if encrypted { + match secrets { + Some(s) => s.decrypt(k), + None => k.to_string(), + } + } else { + k.to_string() + } + }); + let headers = match v["headers"].as_array() { Some(arr) => arr .iter() .filter_map(|h| { let pair = h.as_array()?; - Some((pair[0].as_str()?.into(), pair[1].as_str()?.into())) + let key: String = pair[0].as_str()?.into(); + let raw_val: &str = pair[1].as_str()?; + let val = if encrypted && is_sensitive_key(&key) { + match secrets { + Some(s) => s.decrypt(raw_val), + None => raw_val.to_string(), + } + } else { + raw_val.to_string() + }; + Some((key, val)) }) .collect(), None => vec![], @@ -60,7 +118,10 @@ fn data_source_config_from_json(json: &str) -> Result Result { +pub fn data_source_from_row( + row: &SqliteRow, + secrets: Option<&(dyn SecretStore + Send + Sync)>, +) -> Result { let id: i64 = row.get("id"); let name: String = row.get("name"); let type_str: String = row.get("source_type"); @@ -72,6 +133,6 @@ pub fn data_source_from_row(row: &SqliteRow) -> Result FromRequestParts> for AuthUser +where + A: AuthPort + Send + Sync + 'static, + C: Send + Sync + 'static, + E: Send + Sync + 'static, + W: Send + Sync + 'static, + B: Send + Sync + 'static, + R: Send + Sync + 'static, + H: Send + Sync + 'static, +{ + type Rejection = StatusCode; + + async fn from_request_parts( + parts: &mut Parts, + state: &crate::AppState, + ) -> Result { + let header = parts + .headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .ok_or(StatusCode::UNAUTHORIZED)?; + + let token = header + .strip_prefix("Bearer ") + .ok_or(StatusCode::UNAUTHORIZED)?; + + let user_id = state + .auth + .validate_token(token) + .ok_or(StatusCode::UNAUTHORIZED)?; + + Ok(AuthUser(user_id)) + } +} diff --git a/crates/adapters/http-api/src/lib.rs b/crates/adapters/http-api/src/lib.rs index ab820ba..3807944 100644 --- a/crates/adapters/http-api/src/lib.rs +++ b/crates/adapters/http-api/src/lib.rs @@ -1,21 +1,27 @@ +pub mod extractors; mod routes; use axum::Router; -use domain::{BroadcastPort, ClientRegistry, ConfigRepository, EventPublisher, WidgetStateReader}; +use domain::{ + AuthPort, BroadcastPort, ClientRegistry, ConfigRepository, EventPublisher, PasswordHashPort, + WidgetStateReader, +}; use std::sync::Arc; use tower_http::cors::CorsLayer; use tower_http::services::{ServeDir, ServeFile}; -pub struct AppState { +pub struct AppState { pub config: Arc, pub events: Arc, pub widget_states: Arc, pub broadcaster: Arc, pub clients: Arc, + pub auth: Arc, + pub hasher: Arc, pub spa_dir: Option, } -impl Clone for AppState { +impl Clone for AppState { fn clone(&self) -> Self { Self { config: self.config.clone(), @@ -23,12 +29,14 @@ impl Clone for AppState { widget_states: self.widget_states.clone(), broadcaster: self.broadcaster.clone(), clients: self.clients.clone(), + auth: self.auth.clone(), + hasher: self.hasher.clone(), spa_dir: self.spa_dir.clone(), } } } -pub fn router(state: AppState) -> Router +pub fn router(state: AppState) -> Router where C: ConfigRepository + Send + Sync + 'static, C::Error: std::fmt::Debug + Send, @@ -38,6 +46,8 @@ where B: BroadcastPort + Send + Sync + 'static, B::Error: std::fmt::Debug + Send, R: ClientRegistry + Send + Sync + 'static, + A: AuthPort + Send + Sync + 'static, + H: PasswordHashPort + Send + Sync + 'static, { let spa_dir = state.spa_dir.clone(); @@ -54,9 +64,9 @@ where } } -pub async fn serve( +pub async fn serve( addr: &str, - state: AppState, + state: AppState, ) -> Result<(), std::io::Error> where C: ConfigRepository + Send + Sync + 'static, @@ -67,6 +77,8 @@ where B: BroadcastPort + Send + Sync + 'static, B::Error: std::fmt::Debug + Send, R: ClientRegistry + Send + Sync + 'static, + A: AuthPort + Send + Sync + 'static, + H: PasswordHashPort + Send + Sync + 'static, { let app = router(state); let listener = tokio::net::TcpListener::bind(addr).await?; diff --git a/crates/adapters/http-api/src/routes/auth.rs b/crates/adapters/http-api/src/routes/auth.rs new file mode 100644 index 0000000..d4ee12b --- /dev/null +++ b/crates/adapters/http-api/src/routes/auth.rs @@ -0,0 +1,85 @@ +use crate::AppState; +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::Json; +use domain::{AuthPort, ConfigRepository, PasswordHashPort}; +use serde::{Deserialize, Serialize}; + +type S = State>; + +#[derive(Deserialize)] +pub struct LoginRequest { + pub username: String, + pub password: String, +} + +#[derive(Serialize)] +pub struct LoginResponse { + pub token: String, +} + +#[derive(Serialize)] +pub struct StatusResponse { + pub needs_setup: bool, +} + +pub async fn login( + State(state): S, + Json(body): Json, +) -> Result, (StatusCode, String)> +where + C: ConfigRepository, + C::Error: std::fmt::Debug, + A: AuthPort, + H: PasswordHashPort, +{ + let token = application::auth_service::login( + state.config.as_ref(), + state.auth.as_ref(), + state.hasher.as_ref(), + &body.username, + &body.password, + ) + .await + .map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string()))?; + + Ok(Json(LoginResponse { token })) +} + +pub async fn register( + State(state): S, + Json(body): Json, +) -> Result +where + C: ConfigRepository, + C::Error: std::fmt::Debug, + H: PasswordHashPort, +{ + application::auth_service::register( + state.config.as_ref(), + state.hasher.as_ref(), + &body.username, + &body.password, + ) + .await + .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; + + Ok(StatusCode::CREATED) +} + +pub async fn auth_status( + State(state): S, +) -> Result, StatusCode> +where + C: ConfigRepository, + C::Error: std::fmt::Debug, +{ + let count = state + .config + .count_users() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + Ok(Json(StatusResponse { + needs_setup: count == 0, + })) +} diff --git a/crates/adapters/http-api/src/routes/clients.rs b/crates/adapters/http-api/src/routes/clients.rs index 02ba253..95a50eb 100644 --- a/crates/adapters/http-api/src/routes/clients.rs +++ b/crates/adapters/http-api/src/routes/clients.rs @@ -1,12 +1,16 @@ use crate::AppState; +use crate::extractors::AuthUser; use api_types::ClientDto; use axum::extract::State; use axum::response::Json; use domain::{ClientRegistry, ConfigRepository, EventPublisher}; -type S = State>; +type S = State>; -pub async fn list_clients(State(state): S) -> Json> +pub async fn list_clients( + _auth: AuthUser, + State(state): S, +) -> Json> where C: ConfigRepository, C::Error: std::fmt::Debug, diff --git a/crates/adapters/http-api/src/routes/data_sources.rs b/crates/adapters/http-api/src/routes/data_sources.rs index b3a9cb3..0cd1818 100644 --- a/crates/adapters/http-api/src/routes/data_sources.rs +++ b/crates/adapters/http-api/src/routes/data_sources.rs @@ -1,4 +1,5 @@ use crate::AppState; +use crate::extractors::AuthUser; use api_types::DataSourceDto; use application::ConfigService; use axum::{ @@ -8,10 +9,11 @@ use axum::{ }; use domain::{ConfigRepository, EventPublisher}; -type S = State>; +type S = State>; -pub async fn list_data_sources( - State(state): S, +pub async fn list_data_sources( + _auth: AuthUser, + State(state): S, ) -> Result>, StatusCode> where C: ConfigRepository, @@ -27,8 +29,9 @@ where Ok(Json(sources.iter().map(DataSourceDto::from).collect())) } -pub async fn get_data_source( - State(state): S, +pub async fn get_data_source( + _auth: AuthUser, + State(state): S, Path(id): Path, ) -> Result, StatusCode> where @@ -48,8 +51,9 @@ where } } -pub async fn create_data_source( - State(state): S, +pub async fn create_data_source( + _auth: AuthUser, + State(state): S, Json(body): Json, ) -> Result where @@ -68,8 +72,9 @@ where Ok(StatusCode::CREATED) } -pub async fn update_data_source( - State(state): S, +pub async fn update_data_source( + _auth: AuthUser, + State(state): S, Path(_id): Path, Json(body): Json, ) -> Result @@ -89,8 +94,9 @@ where Ok(StatusCode::OK) } -pub async fn delete_data_source( - State(state): S, +pub async fn delete_data_source( + _auth: AuthUser, + State(state): S, Path(id): Path, ) -> Result where diff --git a/crates/adapters/http-api/src/routes/layout.rs b/crates/adapters/http-api/src/routes/layout.rs index cbbd1ff..033f6be 100644 --- a/crates/adapters/http-api/src/routes/layout.rs +++ b/crates/adapters/http-api/src/routes/layout.rs @@ -1,13 +1,15 @@ use crate::AppState; +use crate::extractors::AuthUser; use api_types::LayoutDto; use application::ConfigService; use axum::{extract::State, http::StatusCode, response::Json}; use domain::{ConfigRepository, EventPublisher}; -type S = State>; +type S = State>; -pub async fn get_layout( - State(state): S, +pub async fn get_layout( + _auth: AuthUser, + State(state): S, ) -> Result>, StatusCode> where C: ConfigRepository, @@ -23,8 +25,9 @@ where Ok(Json(layout.as_ref().map(LayoutDto::from))) } -pub async fn update_layout( - State(state): S, +pub async fn update_layout( + _auth: AuthUser, + State(state): S, Json(body): Json, ) -> Result where diff --git a/crates/adapters/http-api/src/routes/mod.rs b/crates/adapters/http-api/src/routes/mod.rs index 9154447..eb106bb 100644 --- a/crates/adapters/http-api/src/routes/mod.rs +++ b/crates/adapters/http-api/src/routes/mod.rs @@ -1,3 +1,4 @@ +mod auth; mod clients; mod data_sources; mod layout; @@ -8,9 +9,12 @@ mod widgets; use crate::AppState; use axum::Router; use axum::routing::{get, post}; -use domain::{BroadcastPort, ClientRegistry, ConfigRepository, EventPublisher, WidgetStateReader}; +use domain::{ + AuthPort, BroadcastPort, ClientRegistry, ConfigRepository, EventPublisher, PasswordHashPort, + WidgetStateReader, +}; -pub fn api_routes() -> Router> +pub fn api_routes() -> Router> where C: ConfigRepository + Send + Sync + 'static, C::Error: std::fmt::Debug + Send, @@ -20,55 +24,72 @@ where B: BroadcastPort + Send + Sync + 'static, B::Error: std::fmt::Debug + Send, R: ClientRegistry + Send + Sync + 'static, + A: AuthPort + Send + Sync + 'static, + H: PasswordHashPort + Send + Sync + 'static, { Router::new() + // Public auth routes + .route( + "/auth/status", + get(auth::auth_status::), + ) + .route("/auth/login", post(auth::login::)) + .route( + "/auth/register", + post(auth::register::), + ) + // Protected routes .route( "/widgets", - get(widgets::list_widgets::) - .post(widgets::create_widget::), + get(widgets::list_widgets::) + .post(widgets::create_widget::), ) .route( "/widgets/{id}", - get(widgets::get_widget::) - .put(widgets::update_widget::) - .delete(widgets::delete_widget::), + get(widgets::get_widget::) + .put(widgets::update_widget::) + .delete(widgets::delete_widget::), ) .route( "/widgets/{id}/preview", - get(widgets::preview_widget::), + get(widgets::preview_widget::), ) .route( "/data-sources", - get(data_sources::list_data_sources::) - .post(data_sources::create_data_source::), + get(data_sources::list_data_sources::) + .post(data_sources::create_data_source::), ) .route( "/data-sources/{id}", - get(data_sources::get_data_source::) - .put(data_sources::update_data_source::) - .delete(data_sources::delete_data_source::), + get(data_sources::get_data_source::) + .put(data_sources::update_data_source::) + .delete(data_sources::delete_data_source::), ) .route( "/layout", - get(layout::get_layout::).put(layout::update_layout::), + get(layout::get_layout::) + .put(layout::update_layout::), ) .route( "/presets", - get(presets::list_presets::) - .post(presets::create_preset::), + get(presets::list_presets::) + .post(presets::create_preset::), ) .route( "/presets/{id}", - get(presets::get_preset::) - .delete(presets::delete_preset::), + get(presets::get_preset::) + .delete(presets::delete_preset::), ) .route( "/presets/{id}/load", - post(presets::load_preset::), + post(presets::load_preset::), + ) + .route( + "/clients", + get(clients::list_clients::), ) - .route("/clients", get(clients::list_clients::)) .route( "/webhook/{source_id}", - post(webhook::receive_webhook::), + post(webhook::receive_webhook::), ) } diff --git a/crates/adapters/http-api/src/routes/presets.rs b/crates/adapters/http-api/src/routes/presets.rs index 787f3e3..02f11b6 100644 --- a/crates/adapters/http-api/src/routes/presets.rs +++ b/crates/adapters/http-api/src/routes/presets.rs @@ -1,4 +1,5 @@ use crate::AppState; +use crate::extractors::AuthUser; use api_types::{CreatePresetDto, PresetDto}; use application::ConfigService; use axum::{ @@ -8,10 +9,11 @@ use axum::{ }; use domain::{ConfigRepository, EventPublisher}; -type S = State>; +type S = State>; -pub async fn list_presets( - State(state): S, +pub async fn list_presets( + _auth: AuthUser, + State(state): S, ) -> Result>, StatusCode> where C: ConfigRepository, @@ -27,8 +29,9 @@ where Ok(Json(presets.iter().map(PresetDto::from).collect())) } -pub async fn get_preset( - State(state): S, +pub async fn get_preset( + _auth: AuthUser, + State(state): S, Path(id): Path, ) -> Result, StatusCode> where @@ -48,8 +51,9 @@ where } } -pub async fn create_preset( - State(state): S, +pub async fn create_preset( + _auth: AuthUser, + State(state): S, Json(body): Json, ) -> Result where @@ -68,8 +72,9 @@ where Ok(StatusCode::CREATED) } -pub async fn delete_preset( - State(state): S, +pub async fn delete_preset( + _auth: AuthUser, + State(state): S, Path(id): Path, ) -> Result where @@ -85,8 +90,9 @@ where Ok(StatusCode::NO_CONTENT) } -pub async fn load_preset( - State(state): S, +pub async fn load_preset( + _auth: AuthUser, + State(state): S, Path(id): Path, ) -> Result where diff --git a/crates/adapters/http-api/src/routes/webhook.rs b/crates/adapters/http-api/src/routes/webhook.rs index e269c09..f92d35f 100644 --- a/crates/adapters/http-api/src/routes/webhook.rs +++ b/crates/adapters/http-api/src/routes/webhook.rs @@ -4,10 +4,10 @@ use axum::http::StatusCode; use axum::response::Json; use domain::{BroadcastPort, ConfigRepository, EventPublisher, WidgetStateReader}; -type S = State>; +type S = State>; -pub async fn receive_webhook( - State(state): S, +pub async fn receive_webhook( + State(state): S, Path(source_id): Path, Json(body): Json, ) -> Result diff --git a/crates/adapters/http-api/src/routes/widgets.rs b/crates/adapters/http-api/src/routes/widgets.rs index 8ff9569..d5c8883 100644 --- a/crates/adapters/http-api/src/routes/widgets.rs +++ b/crates/adapters/http-api/src/routes/widgets.rs @@ -1,4 +1,5 @@ use crate::AppState; +use crate::extractors::AuthUser; use api_types::{CreateWidgetDto, WidgetDto}; use application::ConfigService; use axum::{ @@ -8,10 +9,11 @@ use axum::{ }; use domain::{ConfigRepository, EventPublisher, WidgetStateReader}; -type S = State>; +type S = State>; -pub async fn list_widgets( - State(state): S, +pub async fn list_widgets( + _auth: AuthUser, + State(state): S, ) -> Result>, StatusCode> where C: ConfigRepository, @@ -27,8 +29,9 @@ where Ok(Json(widgets.iter().map(WidgetDto::from).collect())) } -pub async fn get_widget( - State(state): S, +pub async fn get_widget( + _auth: AuthUser, + State(state): S, Path(id): Path, ) -> Result, StatusCode> where @@ -48,8 +51,9 @@ where } } -pub async fn create_widget( - State(state): S, +pub async fn create_widget( + _auth: AuthUser, + State(state): S, Json(body): Json, ) -> Result where @@ -68,8 +72,9 @@ where Ok(StatusCode::CREATED) } -pub async fn update_widget( - State(state): S, +pub async fn update_widget( + _auth: AuthUser, + State(state): S, Path(_id): Path, Json(body): Json, ) -> Result @@ -89,8 +94,9 @@ where Ok(StatusCode::OK) } -pub async fn delete_widget( - State(state): S, +pub async fn delete_widget( + _auth: AuthUser, + State(state): S, Path(id): Path, ) -> Result where @@ -106,8 +112,9 @@ where Ok(StatusCode::NO_CONTENT) } -pub async fn preview_widget( - State(state): S, +pub async fn preview_widget( + _auth: AuthUser, + State(state): S, Path(id): Path, ) -> Result, StatusCode> where diff --git a/crates/adapters/http-api/tests/api_tests.rs b/crates/adapters/http-api/tests/api_tests.rs index aab9562..d639464 100644 --- a/crates/adapters/http-api/tests/api_tests.rs +++ b/crates/adapters/http-api/tests/api_tests.rs @@ -2,11 +2,32 @@ use application::DataProjection; use axum::body::Body; use axum::http::{Request, StatusCode}; use config_memory::MemoryConfigStore; +use domain::{AuthPort, PasswordHashPort, UserId}; use http_api::{AppState, router}; use std::sync::Arc; use tcp_server::{ClientTracker, TcpBroadcaster, TcpEventBus}; use tower::ServiceExt; +struct TestAuth; +impl AuthPort for TestAuth { + fn generate_token(&self, _user_id: UserId) -> String { + "test-token".into() + } + fn validate_token(&self, token: &str) -> Option { + if token == "test-token" { Some(1) } else { None } + } +} + +struct TestHasher; +impl PasswordHashPort for TestHasher { + async fn hash(&self, _plain: &str) -> Result { + Ok("hashed".into()) + } + async fn verify(&self, _plain: &str, _hash: &str) -> Result { + Ok(true) + } +} + fn test_app() -> axum::Router { let state = AppState { config: Arc::new(MemoryConfigStore::new()), @@ -14,13 +35,29 @@ fn test_app() -> axum::Router { widget_states: Arc::new(DataProjection::new()), broadcaster: Arc::new(TcpBroadcaster::new(16)), clients: Arc::new(ClientTracker::new()), + auth: Arc::new(TestAuth), + hasher: Arc::new(TestHasher), spa_dir: None, }; router(state) } +fn authed_json_request(method: &str, uri: &str, body: Option<&str>) -> Request { + let builder = Request::builder() + .method(method) + .uri(uri) + .header("content-type", "application/json") + .header("authorization", "Bearer test-token"); + + if let Some(b) = body { + builder.body(Body::from(b.to_string())).unwrap() + } else { + builder.body(Body::empty()).unwrap() + } +} + fn json_request(method: &str, uri: &str, body: Option<&str>) -> Request { - let mut builder = Request::builder() + let builder = Request::builder() .method(method) .uri(uri) .header("content-type", "application/json"); @@ -32,6 +69,16 @@ fn json_request(method: &str, uri: &str, body: Option<&str>) -> Request { } } +#[tokio::test] +async fn unauthenticated_request_returns_401() { + let app = test_app(); + let resp = app + .oneshot(json_request("GET", "/api/widgets", None)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + #[tokio::test] async fn create_and_get_widget() { let app = test_app(); @@ -46,13 +93,13 @@ async fn create_and_get_widget() { let resp = app .clone() - .oneshot(json_request("POST", "/api/widgets", Some(body))) + .oneshot(authed_json_request("POST", "/api/widgets", Some(body))) .await .unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); let resp = app - .oneshot(json_request("GET", "/api/widgets/1", None)) + .oneshot(authed_json_request("GET", "/api/widgets/1", None)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); @@ -62,8 +109,6 @@ async fn create_and_get_widget() { .unwrap(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(json["name"], "weather"); - assert_eq!(json["display_hint"], "icon_value"); - assert_eq!(json["data_source_id"], 10); } #[tokio::test] @@ -74,16 +119,16 @@ async fn list_widgets() { let w2 = r#"{"id":2,"name":"b","display_hint":"key_value","data_source_id":2,"mappings":[]}"#; app.clone() - .oneshot(json_request("POST", "/api/widgets", Some(w1))) + .oneshot(authed_json_request("POST", "/api/widgets", Some(w1))) .await .unwrap(); app.clone() - .oneshot(json_request("POST", "/api/widgets", Some(w2))) + .oneshot(authed_json_request("POST", "/api/widgets", Some(w2))) .await .unwrap(); let resp = app - .oneshot(json_request("GET", "/api/widgets", None)) + .oneshot(authed_json_request("GET", "/api/widgets", None)) .await .unwrap(); let body = axum::body::to_bytes(resp.into_body(), usize::MAX) @@ -100,19 +145,19 @@ async fn delete_widget() { let body = r#"{"id":1,"name":"a","display_hint":"icon_value","data_source_id":1,"mappings":[]}"#; app.clone() - .oneshot(json_request("POST", "/api/widgets", Some(body))) + .oneshot(authed_json_request("POST", "/api/widgets", Some(body))) .await .unwrap(); let resp = app .clone() - .oneshot(json_request("DELETE", "/api/widgets/1", None)) + .oneshot(authed_json_request("DELETE", "/api/widgets/1", None)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NO_CONTENT); let resp = app - .oneshot(json_request("GET", "/api/widgets/1", None)) + .oneshot(authed_json_request("GET", "/api/widgets/1", None)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); @@ -134,23 +179,16 @@ async fn create_and_get_data_source() { let resp = app .clone() - .oneshot(json_request("POST", "/api/data-sources", Some(body))) + .oneshot(authed_json_request("POST", "/api/data-sources", Some(body))) .await .unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); let resp = app - .oneshot(json_request("GET", "/api/data-sources/10", None)) + .oneshot(authed_json_request("GET", "/api/data-sources/10", None)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); - - let body = axum::body::to_bytes(resp.into_body(), usize::MAX) - .await - .unwrap(); - let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); - assert_eq!(json["name"], "weather_api"); - assert_eq!(json["poll_interval_secs"], 300); } #[tokio::test] @@ -172,24 +210,16 @@ async fn update_and_get_layout() { let resp = app .clone() - .oneshot(json_request("PUT", "/api/layout", Some(body))) + .oneshot(authed_json_request("PUT", "/api/layout", Some(body))) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let resp = app - .oneshot(json_request("GET", "/api/layout", None)) + .oneshot(authed_json_request("GET", "/api/layout", None)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); - - let body = axum::body::to_bytes(resp.into_body(), usize::MAX) - .await - .unwrap(); - let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); - assert_eq!(json["root"]["type"], "container"); - assert_eq!(json["root"]["direction"], "row"); - assert_eq!(json["root"]["children"].as_array().unwrap().len(), 2); } #[tokio::test] @@ -198,14 +228,23 @@ async fn get_nonexistent_returns_404() { let resp = app .clone() - .oneshot(json_request("GET", "/api/widgets/99", None)) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::NOT_FOUND); - - let resp = app - .oneshot(json_request("GET", "/api/data-sources/99", None)) + .oneshot(authed_json_request("GET", "/api/widgets/99", None)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } + +#[tokio::test] +async fn auth_status_returns_needs_setup() { + let app = test_app(); + let resp = app + .oneshot(json_request("GET", "/api/auth/status", None)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["needs_setup"], true); +} diff --git a/crates/adapters/secret-store/Cargo.toml b/crates/adapters/secret-store/Cargo.toml new file mode 100644 index 0000000..3401a89 --- /dev/null +++ b/crates/adapters/secret-store/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "secret-store" +version = "0.1.0" +edition = "2024" + +[dependencies] +domain.workspace = true +aes-gcm = "0.10" +base64 = "0.22" +hex = "0.4" +rand_core = { version = "0.6", features = ["getrandom"] } diff --git a/crates/adapters/secret-store/src/lib.rs b/crates/adapters/secret-store/src/lib.rs new file mode 100644 index 0000000..7a5e3e8 --- /dev/null +++ b/crates/adapters/secret-store/src/lib.rs @@ -0,0 +1,56 @@ +use aes_gcm::{Aes256Gcm, Key, KeyInit, Nonce, aead::Aead}; +use base64::{Engine, engine::general_purpose::STANDARD as B64}; +use domain::SecretStore; +use rand_core::{OsRng, RngCore}; + +pub struct AesSecretStore { + key: Key, +} + +impl AesSecretStore { + pub fn from_env() -> Result { + let hex_key = std::env::var("KFRAME_ENCRYPTION_KEY") + .map_err(|_| "KFRAME_ENCRYPTION_KEY env var is required".to_string())?; + let bytes = hex::decode(&hex_key) + .map_err(|e| format!("KFRAME_ENCRYPTION_KEY must be 64 hex chars: {e}"))?; + if bytes.len() != 32 { + return Err(format!( + "KFRAME_ENCRYPTION_KEY must be 32 bytes (64 hex chars), got {}", + bytes.len() + )); + } + let key = Key::::from_slice(&bytes); + Ok(Self { key: *key }) + } +} + +impl SecretStore for AesSecretStore { + fn encrypt(&self, plaintext: &str) -> String { + let cipher = Aes256Gcm::new(&self.key); + let mut nonce_bytes = [0u8; 12]; + OsRng.fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + let ciphertext = cipher + .encrypt(nonce, plaintext.as_bytes()) + .expect("AES-GCM encryption should not fail"); + let mut combined = nonce_bytes.to_vec(); + combined.extend(ciphertext); + B64.encode(combined) + } + + fn decrypt(&self, ciphertext: &str) -> String { + let combined = B64 + .decode(ciphertext) + .expect("invalid base64 in encrypted field"); + if combined.len() < 12 { + panic!("encrypted data too short"); + } + let (nonce_bytes, ct) = combined.split_at(12); + let cipher = Aes256Gcm::new(&self.key); + let nonce = Nonce::from_slice(nonce_bytes); + let plaintext = cipher + .decrypt(nonce, ct) + .expect("AES-GCM decryption failed — wrong key or corrupted data"); + String::from_utf8(plaintext).expect("decrypted data is not valid UTF-8") + } +} diff --git a/crates/application/src/auth_service.rs b/crates/application/src/auth_service.rs new file mode 100644 index 0000000..0e2cf9e --- /dev/null +++ b/crates/application/src/auth_service.rs @@ -0,0 +1,79 @@ +use domain::{AuthPort, ConfigRepository, PasswordHashPort, User}; + +pub enum AuthError { + InvalidCredentials, + RegistrationClosed, + Repository(E), + Hash(String), +} + +impl std::fmt::Display for AuthError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidCredentials => write!(f, "invalid credentials"), + Self::RegistrationClosed => write!(f, "registration closed (users already exist)"), + Self::Repository(e) => write!(f, "repository error: {e:?}"), + Self::Hash(e) => write!(f, "hash error: {e}"), + } + } +} + +pub async fn login( + config: &C, + auth: &A, + hasher: &H, + username: &str, + password: &str, +) -> Result> +where + C: ConfigRepository, + A: AuthPort, + H: PasswordHashPort, +{ + let user = config + .get_user_by_username(username) + .await + .map_err(AuthError::Repository)? + .ok_or(AuthError::InvalidCredentials)?; + + let valid = hasher + .verify(password, &user.password_hash) + .await + .map_err(AuthError::Hash)?; + + if !valid { + return Err(AuthError::InvalidCredentials); + } + + Ok(auth.generate_token(user.id)) +} + +pub async fn register( + config: &C, + hasher: &H, + username: &str, + password: &str, +) -> Result<(), AuthError> +where + C: ConfigRepository, + H: PasswordHashPort, +{ + let count = config.count_users().await.map_err(AuthError::Repository)?; + if count > 0 { + return Err(AuthError::RegistrationClosed); + } + + let hash = hasher.hash(password).await.map_err(AuthError::Hash)?; + + let user = User { + id: 0, + username: username.to_string(), + password_hash: hash, + }; + + config + .save_user(&user) + .await + .map_err(AuthError::Repository)?; + Ok(()) +} diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 1e2cd82..63427c2 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -1,3 +1,4 @@ +pub mod auth_service; mod config_service; mod data_projection; diff --git a/crates/application/tests/support/mod.rs b/crates/application/tests/support/mod.rs index 4027bf5..f781f3d 100644 --- a/crates/application/tests/support/mod.rs +++ b/crates/application/tests/support/mod.rs @@ -1,6 +1,6 @@ use domain::{ ConfigRepository, DataSource, DataSourceId, DomainEvent, EventPublisher, Layout, LayoutPreset, - LayoutPresetId, WidgetConfig, WidgetId, + LayoutPresetId, User, WidgetConfig, WidgetId, }; use std::collections::HashMap; use std::sync::Mutex; @@ -112,6 +112,18 @@ impl ConfigRepository for InMemoryConfigRepository { self.presets.lock().unwrap().remove(&id); Ok(()) } + + async fn get_user_by_username(&self, _username: &str) -> Result, Self::Error> { + Ok(None) + } + + async fn save_user(&self, _user: &User) -> Result<(), Self::Error> { + Ok(()) + } + + async fn count_users(&self) -> Result { + Ok(0) + } } pub struct InMemoryEventPublisher { diff --git a/crates/bootstrap/Cargo.toml b/crates/bootstrap/Cargo.toml index ff80512..dbe9962 100644 --- a/crates/bootstrap/Cargo.toml +++ b/crates/bootstrap/Cargo.toml @@ -12,6 +12,8 @@ http-api.workspace = true http-json.workspace = true media-adapter.workspace = true rss-adapter.workspace = true +kframe-auth.workspace = true +secret-store.workspace = true tokio.workspace = true anyhow.workspace = true tracing.workspace = true diff --git a/crates/bootstrap/src/main.rs b/crates/bootstrap/src/main.rs index a08c976..c4e291d 100644 --- a/crates/bootstrap/src/main.rs +++ b/crates/bootstrap/src/main.rs @@ -6,6 +6,8 @@ use anyhow::Result; use application::DataProjection; use config_sqlite::SqliteConfigStore; use http_api::AppState; +use kframe_auth::{Argon2Hasher, AuthConfig, JwtAuthService}; +use secret_store::AesSecretStore; use std::sync::Arc; use tcp_server::{ClientTracker, TcpBroadcaster, TcpEventBus, run_tcp_server}; use tracing::{error, info}; @@ -23,13 +25,20 @@ async fn main() -> Result<()> { let cfg = config::ServerConfig::from_env(); + let auth_config = AuthConfig::from_env().map_err(|e| anyhow::anyhow!(e))?; + let secrets = AesSecretStore::from_env().map_err(|e| anyhow::anyhow!(e))?; + info!(db = %cfg.database_url, "connecting to database"); - let config_store = Arc::new(SqliteConfigStore::new(&cfg.database_url).await?); + let secrets = Arc::new(secrets); + let config_store = + Arc::new(SqliteConfigStore::with_secrets(&cfg.database_url, Some(secrets.clone())).await?); let event_bus = Arc::new(TcpEventBus::new(64)); let broadcaster = Arc::new(TcpBroadcaster::new(64)); let projection = Arc::new(DataProjection::new()); let tracker = Arc::new(ClientTracker::new()); + let auth = Arc::new(JwtAuthService::new(auth_config)); + let hasher = Arc::new(Argon2Hasher); let tcp_addr = cfg.tcp_addr.clone(); let tcp_bc = broadcaster.clone(); @@ -50,6 +59,8 @@ async fn main() -> Result<()> { widget_states: projection.clone(), broadcaster: broadcaster.clone(), clients: tracker.clone(), + auth: auth.clone(), + hasher: hasher.clone(), spa_dir: cfg.spa_dir, }; tokio::spawn(async move { diff --git a/crates/domain/src/entities/mod.rs b/crates/domain/src/entities/mod.rs index 38672ad..cd173be 100644 --- a/crates/domain/src/entities/mod.rs +++ b/crates/domain/src/entities/mod.rs @@ -1,9 +1,11 @@ mod data_source; mod layout_preset; +mod user; mod widget_config; pub use data_source::{ DataSource, DataSourceConfig, DataSourceId, DataSourceType, DataSourceValidationError, }; pub use layout_preset::{LayoutPreset, LayoutPresetId}; +pub use user::{User, UserId}; pub use widget_config::{WidgetConfig, WidgetId}; diff --git a/crates/domain/src/entities/user.rs b/crates/domain/src/entities/user.rs new file mode 100644 index 0000000..1fc37e8 --- /dev/null +++ b/crates/domain/src/entities/user.rs @@ -0,0 +1,8 @@ +pub type UserId = u32; + +#[derive(Debug, Clone)] +pub struct User { + pub id: UserId, + pub username: String, + pub password_hash: String, +} diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 95df3b8..b29173c 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -7,12 +7,12 @@ pub mod value_objects; pub use entities::{ DataSource, DataSourceConfig, DataSourceId, DataSourceType, DataSourceValidationError, - LayoutPreset, LayoutPresetId, WidgetConfig, WidgetId, + LayoutPreset, LayoutPresetId, User, UserId, WidgetConfig, WidgetId, }; pub use events::DomainEvent; pub use ports::{ - BroadcastPort, ClientRegistry, ConfigRepository, ConnectedClient, DataSourcePort, - EventPublisher, WidgetStateReader, + AuthPort, BroadcastPort, ClientRegistry, ConfigRepository, ConnectedClient, DataSourcePort, + EventPublisher, PasswordHashPort, SecretStore, WidgetStateReader, }; pub use value_objects::{ ContainerNode, Direction, DisplayHint, KeyMapping, Layout, LayoutChild, LayoutNode, diff --git a/crates/domain/src/ports/auth.rs b/crates/domain/src/ports/auth.rs new file mode 100644 index 0000000..ce77930 --- /dev/null +++ b/crates/domain/src/ports/auth.rs @@ -0,0 +1,12 @@ +use crate::entities::UserId; +use std::future::Future; + +pub trait AuthPort { + fn generate_token(&self, user_id: UserId) -> String; + fn validate_token(&self, token: &str) -> Option; +} + +pub trait PasswordHashPort { + fn hash(&self, plain: &str) -> impl Future> + Send; + fn verify(&self, plain: &str, hash: &str) -> impl Future> + Send; +} diff --git a/crates/domain/src/ports/config_repository.rs b/crates/domain/src/ports/config_repository.rs index df2a4a0..27842b9 100644 --- a/crates/domain/src/ports/config_repository.rs +++ b/crates/domain/src/ports/config_repository.rs @@ -1,5 +1,5 @@ use crate::entities::{ - DataSource, DataSourceId, LayoutPreset, LayoutPresetId, WidgetConfig, WidgetId, + DataSource, DataSourceId, LayoutPreset, LayoutPresetId, User, WidgetConfig, WidgetId, }; use crate::value_objects::Layout; use std::future::Future; @@ -50,4 +50,11 @@ pub trait ConfigRepository { &self, id: LayoutPresetId, ) -> impl Future> + Send; + + fn get_user_by_username( + &self, + username: &str, + ) -> impl Future, Self::Error>> + Send; + fn save_user(&self, user: &User) -> impl Future> + Send; + fn count_users(&self) -> impl Future> + Send; } diff --git a/crates/domain/src/ports/mod.rs b/crates/domain/src/ports/mod.rs index a950cd1..20e7049 100644 --- a/crates/domain/src/ports/mod.rs +++ b/crates/domain/src/ports/mod.rs @@ -1,13 +1,17 @@ +mod auth; mod broadcast; mod client_registry; mod config_repository; mod data_source_port; mod event; +mod secret_store; mod widget_state_reader; +pub use auth::{AuthPort, PasswordHashPort}; pub use broadcast::BroadcastPort; pub use client_registry::{ClientRegistry, ConnectedClient}; pub use config_repository::ConfigRepository; pub use data_source_port::DataSourcePort; pub use event::EventPublisher; +pub use secret_store::SecretStore; pub use widget_state_reader::WidgetStateReader; diff --git a/crates/domain/src/ports/secret_store.rs b/crates/domain/src/ports/secret_store.rs new file mode 100644 index 0000000..f12072b --- /dev/null +++ b/crates/domain/src/ports/secret_store.rs @@ -0,0 +1,4 @@ +pub trait SecretStore { + fn encrypt(&self, plaintext: &str) -> String; + fn decrypt(&self, ciphertext: &str) -> String; +} diff --git a/spa/src/api/auth.ts b/spa/src/api/auth.ts new file mode 100644 index 0000000..3660d55 --- /dev/null +++ b/spa/src/api/auth.ts @@ -0,0 +1,59 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" + +const BASE = "/api" + +export function useAuthStatus() { + return useQuery({ + queryKey: ["auth-status"], + queryFn: async () => { + const res = await fetch(`${BASE}/auth/status`) + return res.json() as Promise<{ needs_setup: boolean }> + }, + }) +} + +export function useLogin() { + const qc = useQueryClient() + return useMutation({ + mutationFn: async (creds: { username: string; password: string }) => { + const res = await fetch(`${BASE}/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(creds), + }) + if (!res.ok) { + const text = await res.text().catch(() => res.statusText) + throw new Error(text) + } + return res.json() as Promise<{ token: string }> + }, + onSuccess: (data) => { + localStorage.setItem("kframe_token", data.token) + qc.invalidateQueries() + }, + }) +} + +export function useRegister() { + return useMutation({ + mutationFn: async (creds: { username: string; password: string }) => { + const res = await fetch(`${BASE}/auth/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(creds), + }) + if (!res.ok) { + const text = await res.text().catch(() => res.statusText) + throw new Error(text) + } + }, + }) +} + +export function getToken(): string | null { + return localStorage.getItem("kframe_token") +} + +export function clearToken() { + localStorage.removeItem("kframe_token") +} diff --git a/spa/src/api/client.ts b/spa/src/api/client.ts index 7c39830..f82c2b6 100644 --- a/spa/src/api/client.ts +++ b/spa/src/api/client.ts @@ -1,13 +1,25 @@ +import { getToken, clearToken } from "./auth" + const BASE = "/api" async function request(path: string, init?: RequestInit): Promise { - const res = await fetch(`${BASE}${path}`, { - ...init, - headers: { - "Content-Type": "application/json", - ...init?.headers, - }, - }) + const token = getToken() + const headers: Record = { + "Content-Type": "application/json", + ...(init?.headers as Record), + } + if (token) { + headers["Authorization"] = `Bearer ${token}` + } + + const res = await fetch(`${BASE}${path}`, { ...init, headers }) + + if (res.status === 401) { + clearToken() + window.location.href = "/login" + throw new Error("Unauthorized") + } + if (!res.ok) { const text = await res.text().catch(() => res.statusText) throw new Error(`${res.status}: ${text}`) diff --git a/spa/src/components/app-shell.tsx b/spa/src/components/app-shell.tsx index 7b97e5e..b76bd04 100644 --- a/spa/src/components/app-shell.tsx +++ b/spa/src/components/app-shell.tsx @@ -1,8 +1,9 @@ -import { Link, useRouterState } from "@tanstack/react-router" +import { Link, useNavigate, useRouterState } from "@tanstack/react-router" import { SidebarProvider, Sidebar, SidebarContent, + SidebarFooter, SidebarHeader, SidebarMenu, SidebarMenuItem, @@ -12,6 +13,8 @@ import { } from "@/components/ui/sidebar" import { Separator } from "@/components/ui/separator" import { Toaster } from "@/components/ui/sonner" +import { Button } from "@/components/ui/button" +import { clearToken } from "@/api/auth" import { LayoutDashboard, Database, @@ -19,6 +22,7 @@ import { Layers, Save, BookOpen, + LogOut, } from "lucide-react" const NAV = [ @@ -32,6 +36,12 @@ const NAV = [ export function AppShell({ children }: { children: React.ReactNode }) { const { location } = useRouterState() + const navigate = useNavigate() + + function logout() { + clearToken() + navigate({ to: "/login" }) + } return ( @@ -59,6 +69,12 @@ export function AppShell({ children }: { children: React.ReactNode }) { })} + + +
diff --git a/spa/src/pages/login.tsx b/spa/src/pages/login.tsx new file mode 100644 index 0000000..20c6fce --- /dev/null +++ b/spa/src/pages/login.tsx @@ -0,0 +1,79 @@ +import { useState } from "react" +import { useNavigate } from "@tanstack/react-router" +import { useLogin, useRegister, useAuthStatus } from "@/api/auth" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { toast } from "sonner" + +export function LoginPage() { + const navigate = useNavigate() + const { data: status } = useAuthStatus() + const login = useLogin() + const register = useRegister() + + const [username, setUsername] = useState("") + const [password, setPassword] = useState("") + + const isSetup = status?.needs_setup ?? false + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + try { + if (isSetup) { + await register.mutateAsync({ username, password }) + toast.success("Account created") + await login.mutateAsync({ username, password }) + } else { + await login.mutateAsync({ username, password }) + } + navigate({ to: "/" }) + } catch (err) { + toast.error(String(err)) + } + } + + return ( +
+ + + + {isSetup ? "K-Frame Setup" : "K-Frame Login"} + + + +
+ {isSetup && ( +

+ Create your admin account to get started. +

+ )} +
+ + setUsername(e.target.value)} + autoFocus + /> +
+
+ + setPassword(e.target.value)} + /> +
+ +
+
+
+
+ ) +} diff --git a/spa/src/router.tsx b/spa/src/router.tsx index 986d70a..d21c1cb 100644 --- a/spa/src/router.tsx +++ b/spa/src/router.tsx @@ -3,6 +3,7 @@ import { createRoute, createRouter, Outlet, + redirect, } from "@tanstack/react-router" import { AppShell } from "@/components/app-shell" import { DashboardPage } from "@/pages/dashboard" @@ -11,8 +12,35 @@ import { WidgetsPage } from "@/pages/widgets" import { LayoutBuilderPage } from "@/pages/layout-builder" import { PresetsPage } from "@/pages/presets" import { GuidePage } from "@/pages/guide" +import { LoginPage } from "@/pages/login" +import { getToken } from "@/api/auth" +import { Toaster } from "@/components/ui/sonner" + +function requireAuth() { + if (!getToken()) { + throw redirect({ to: "/login" }) + } +} const rootRoute = createRootRoute({ + component: () => , +}) + +const loginRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/login", + component: () => ( + <> + + + + ), +}) + +const authenticatedRoute = createRoute({ + getParentRoute: () => rootRoute, + id: "authenticated", + beforeLoad: requireAuth, component: () => ( @@ -21,48 +49,51 @@ const rootRoute = createRootRoute({ }) const indexRoute = createRoute({ - getParentRoute: () => rootRoute, + getParentRoute: () => authenticatedRoute, path: "/", component: DashboardPage, }) const dataSourcesRoute = createRoute({ - getParentRoute: () => rootRoute, + getParentRoute: () => authenticatedRoute, path: "/data-sources", component: DataSourcesPage, }) const widgetsRoute = createRoute({ - getParentRoute: () => rootRoute, + getParentRoute: () => authenticatedRoute, path: "/widgets", component: WidgetsPage, }) const layoutRoute = createRoute({ - getParentRoute: () => rootRoute, + getParentRoute: () => authenticatedRoute, path: "/layout", component: LayoutBuilderPage, }) const presetsRoute = createRoute({ - getParentRoute: () => rootRoute, + getParentRoute: () => authenticatedRoute, path: "/presets", component: PresetsPage, }) const guideRoute = createRoute({ - getParentRoute: () => rootRoute, + getParentRoute: () => authenticatedRoute, path: "/guide", component: GuidePage, }) const routeTree = rootRoute.addChildren([ - indexRoute, - dataSourcesRoute, - widgetsRoute, - layoutRoute, - presetsRoute, - guideRoute, + loginRoute, + authenticatedRoute.addChildren([ + indexRoute, + dataSourcesRoute, + widgetsRoute, + layoutRoute, + presetsRoute, + guideRoute, + ]), ]) export const router = createRouter({ routeTree })