diff --git a/Cargo.lock b/Cargo.lock index bc0b732..5c8f2d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -338,12 +338,24 @@ dependencies = [ "syn", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -694,6 +706,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -747,8 +771,18 @@ version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] @@ -765,13 +799,37 @@ dependencies = [ "syn", ] +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + [[package]] name = "darling_macro" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core", + "darling_core 0.20.11", + "quote", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", "quote", "syn", ] @@ -810,6 +868,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + [[package]] name = "derive_builder" version = "0.20.2" @@ -825,7 +893,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" dependencies = [ - "darling", + "darling 0.20.11", "proc-macro2", "quote", "syn", @@ -891,6 +959,26 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + [[package]] name = "ed25519" version = "2.2.3" @@ -925,6 +1013,27 @@ dependencies = [ "serde", ] +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "encode_unicode" version = "1.0.0" @@ -1071,6 +1180,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -1258,6 +1377,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1364,6 +1484,17 @@ dependencies = [ "web-time", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "h2" version = "0.4.13" @@ -1376,7 +1507,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap", + "indexmap 2.13.0", "slab", "tokio", "tokio-util", @@ -1394,6 +1525,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.14.5" @@ -1790,6 +1927,17 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -1876,6 +2024,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -2314,6 +2471,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + [[package]] name = "num-derive" version = "0.4.2" @@ -2372,6 +2535,26 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" +[[package]] +name = "oauth2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" +dependencies = [ + "base64 0.22.1", + "chrono", + "getrandom 0.2.17", + "http", + "rand 0.8.5", + "reqwest", + "serde", + "serde_json", + "serde_path_to_error", + "sha2", + "thiserror 1.0.69", + "url", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -2423,6 +2606,37 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openidconnect" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c6709ba2ea764bbed26bce1adf3c10517113ddea6f2d4196e4851757ef2b2" +dependencies = [ + "base64 0.21.7", + "chrono", + "dyn-clone", + "ed25519-dalek", + "hmac", + "http", + "itertools 0.10.5", + "log", + "oauth2", + "p256", + "p384", + "rand 0.8.5", + "rsa", + "serde", + "serde-value", + "serde_json", + "serde_path_to_error", + "serde_plain", + "serde_with", + "sha2", + "subtle", + "thiserror 1.0.69", + "url", +] + [[package]] name = "openssl" version = "0.10.75" @@ -2479,6 +2693,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + [[package]] name = "ort" version = "2.0.0-rc.9" @@ -2503,6 +2726,30 @@ dependencies = [ "ureq", ] +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + [[package]] name = "parking" version = "2.2.1" @@ -2693,6 +2940,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" @@ -2712,6 +2965,15 @@ dependencies = [ "syn", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -2921,7 +3183,7 @@ dependencies = [ "built", "cfg-if", "interpolate_name", - "itertools", + "itertools 0.14.0", "libc", "libfuzzer-sys", "log", @@ -2987,7 +3249,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f" dependencies = [ "either", - "itertools", + "itertools 0.14.0", "rayon", ] @@ -3030,6 +3292,26 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "regex" version = "1.12.3" @@ -3106,6 +3388,16 @@ dependencies = [ "webpki-roots 1.0.6", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "rgb" version = "0.8.53" @@ -3231,12 +3523,50 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -3276,6 +3606,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -3320,6 +3660,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_plain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -3332,6 +3681,37 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.13.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sha1" version = "0.10.6" @@ -3512,7 +3892,7 @@ dependencies = [ "futures-util", "hashbrown 0.15.5", "hashlink", - "indexmap", + "indexmap 2.13.0", "log", "memchr", "once_cell", @@ -3856,6 +4236,37 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -3894,7 +4305,7 @@ dependencies = [ "derive_builder", "esaxx-rs", "getrandom 0.3.4", - "itertools", + "itertools 0.14.0", "log", "macro_rules_attribute", "monostate", @@ -4094,8 +4505,22 @@ dependencies = [ name = "tracevault-enterprise" version = "0.1.0" dependencies = [ + "aes-gcm", "async-trait", + "base64 0.22.1", + "chrono", + "ed25519-dalek", + "glob-match", + "hex", + "openidconnect", + "rand 0.8.5", + "reqwest", + "serde", + "serde_json", + "sha2", "tracevault-core", + "tracing", + "uuid", ] [[package]] @@ -4131,6 +4556,7 @@ dependencies = [ "tracevault-enterprise", "tracing", "tracing-subscriber", + "urlencoding", "uuid", ] @@ -4403,8 +4829,15 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -4573,7 +5006,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.13.0", "wasm-encoder", "wasmparser", ] @@ -4599,7 +5032,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.13.0", "semver", ] @@ -5008,7 +5441,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap", + "indexmap 2.13.0", "prettyplease", "syn", "wasm-metadata", @@ -5039,7 +5472,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags", - "indexmap", + "indexmap 2.13.0", "log", "serde", "serde_derive", @@ -5058,7 +5491,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.13.0", "log", "semver", "serde", diff --git a/crates/tracevault-core/src/extensions.rs b/crates/tracevault-core/src/extensions.rs index ae33fe9..e3db52d 100644 --- a/crates/tracevault-core/src/extensions.rs +++ b/crates/tracevault-core/src/extensions.rs @@ -33,6 +33,7 @@ pub struct ExtensionRegistry { pub pricing: Arc, pub compliance: Arc, pub permissions: Arc, + pub sso: Arc, } impl Clone for ExtensionRegistry { @@ -44,6 +45,7 @@ impl Clone for ExtensionRegistry { pricing: Arc::clone(&self.pricing), compliance: Arc::clone(&self.compliance), permissions: Arc::clone(&self.permissions), + sso: Arc::clone(&self.sso), } } } @@ -106,3 +108,37 @@ pub trait PermissionsProvider: Send + Sync { fn has_permission(&self, role: &str, perm: Permission) -> bool; fn is_valid_role(&self, role: &str) -> bool; } + +// -- SSO -- + +#[derive(Debug, Clone)] +pub struct SsoUserInfo { + pub subject: String, + pub email: String, + pub name: Option, +} + +#[async_trait] +pub trait SsoProvider: Send + Sync { + fn is_enabled(&self) -> bool; + + /// Build the OIDC authorization URL that the user's browser should be redirected to. + async fn authorization_url( + &self, + issuer_url: &str, + client_id: &str, + client_secret: &str, + redirect_uri: &str, + state: &str, + ) -> Result; + + /// Exchange an authorization code for user identity claims. + async fn exchange_code( + &self, + issuer_url: &str, + client_id: &str, + client_secret: &str, + redirect_uri: &str, + code: &str, + ) -> Result; +} diff --git a/crates/tracevault-server/Cargo.toml b/crates/tracevault-server/Cargo.toml index ebb5c5b..2e9431f 100644 --- a/crates/tracevault-server/Cargo.toml +++ b/crates/tracevault-server/Cargo.toml @@ -35,6 +35,7 @@ reqwest = { version = "0.12", features = ["json"] } async-trait = "0.1" aes-gcm = "0.10" dotenvy = "0.15.7" +urlencoding = "2" thiserror.workspace = true tower_governor = "0.6" pgvector = { version = "0.4", features = ["sqlx"] } diff --git a/crates/tracevault-server/migrations/018_sso.sql b/crates/tracevault-server/migrations/018_sso.sql new file mode 100644 index 0000000..cbe1f81 --- /dev/null +++ b/crates/tracevault-server/migrations/018_sso.sql @@ -0,0 +1,40 @@ +-- SSO configuration per organization +CREATE TABLE org_sso_configs ( + org_id UUID PRIMARY KEY REFERENCES orgs(id) ON DELETE CASCADE, + issuer_url TEXT NOT NULL, + client_id TEXT NOT NULL, + client_secret_encrypted TEXT NOT NULL, + client_secret_nonce TEXT NOT NULL, + allowed_domains TEXT[] NOT NULL, + enforce BOOLEAN NOT NULL DEFAULT true, + auto_provision BOOLEAN NOT NULL DEFAULT true, + default_role TEXT NOT NULL DEFAULT 'developer', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Link users to external IdP identities +CREATE TABLE user_sso_links ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + org_id UUID NOT NULL REFERENCES orgs(id) ON DELETE CASCADE, + issuer TEXT NOT NULL, + subject TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(org_id, subject), + UNIQUE(user_id, org_id) +); + +-- CSRF state for OIDC authorization flow +CREATE TABLE sso_auth_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + org_id UUID NOT NULL REFERENCES orgs(id), + state TEXT NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_sso_auth_requests_state ON sso_auth_requests(state); + +-- Allow SSO-provisioned users to have no password +ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL; diff --git a/crates/tracevault-server/src/api/auth.rs b/crates/tracevault-server/src/api/auth.rs index a77b362..2061f4c 100644 --- a/crates/tracevault-server/src/api/auth.rs +++ b/crates/tracevault-server/src/api/auth.rs @@ -218,19 +218,75 @@ pub async fn login( State(state): State, Json(req): Json, ) -> Result, AppError> { - let row = - sqlx::query_as::<_, (Uuid, String)>("SELECT id, password_hash FROM users WHERE email = $1") - .bind(&req.email) - .fetch_optional(&state.pool) - .await? - .ok_or(AppError::Unauthorized)?; + let row = sqlx::query_as::<_, (Uuid, Option)>( + "SELECT id, password_hash FROM users WHERE email = $1", + ) + .bind(&req.email) + .fetch_optional(&state.pool) + .await? + .ok_or(AppError::Unauthorized)?; + + let (user_id, password_hash_opt) = row; - let (user_id, password_hash) = row; + let password_hash = password_hash_opt.ok_or(AppError::Unauthorized)?; if !verify_password(&req.password, &password_hash) { return Err(AppError::Unauthorized); } + // Check SSO enforcement + let sso_enforced_orgs = sqlx::query_as::<_, (uuid::Uuid, String)>( + "SELECT m.org_id, m.role FROM user_org_memberships m + JOIN org_sso_configs s ON m.org_id = s.org_id + WHERE m.user_id = $1 AND s.enforce = true", + ) + .bind(user_id) + .fetch_all(&state.pool) + .await?; + + if !sso_enforced_orgs.is_empty() { + // Check if user has any non-SSO org, or is admin/owner in an SSO org + let non_sso_orgs: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM user_org_memberships m + WHERE m.user_id = $1 + AND NOT EXISTS ( + SELECT 1 FROM org_sso_configs s WHERE s.org_id = m.org_id AND s.enforce = true + )", + ) + .bind(user_id) + .fetch_one(&state.pool) + .await?; + + let is_breakglass = sso_enforced_orgs + .iter() + .any(|(_, role)| role == "owner" || role == "admin"); + + if non_sso_orgs == 0 && !is_breakglass { + return Err(AppError::Forbidden( + "Your organization requires SSO login. Use the SSO button on the login page." + .into(), + )); + } + + // Log break-glass if applicable + if is_breakglass && non_sso_orgs == 0 { + for (org_id, _) in &sso_enforced_orgs { + crate::audit::log( + &state.pool, + crate::audit::user_action( + *org_id, + user_id, + "auth.login.breakglass", + "user", + Some(user_id), + Some(serde_json::json!({"email": &req.email})), + ), + ) + .await; + } + } + } + let (raw_token, token_hash) = generate_session_token(); let expires_at = chrono::Utc::now() + chrono::Duration::days(30); diff --git a/crates/tracevault-server/src/api/mod.rs b/crates/tracevault-server/src/api/mod.rs index c3d70d1..63285f9 100644 --- a/crates/tracevault-server/src/api/mod.rs +++ b/crates/tracevault-server/src/api/mod.rs @@ -16,5 +16,6 @@ pub mod policies; pub mod pricing; pub mod repos; pub mod session_detail; +pub mod sso; pub mod stream; pub mod traces_ui; diff --git a/crates/tracevault-server/src/api/sso.rs b/crates/tracevault-server/src/api/sso.rs new file mode 100644 index 0000000..8fd3ce4 --- /dev/null +++ b/crates/tracevault-server/src/api/sso.rs @@ -0,0 +1,640 @@ +use crate::error::{self, AppError}; +use crate::extractors::OrgAuth; +use crate::permissions::Permission; +use crate::AppState; +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::Redirect; +use axum::Json; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +// --- SSO Status (public) --- + +#[derive(Serialize)] +pub struct SsoStatusResponse { + pub sso_enabled: bool, + pub enforce: bool, +} + +pub async fn sso_status( + State(state): State, + axum::extract::Path(slug): axum::extract::Path, +) -> Result, AppError> { + let org_row = sqlx::query_as::<_, (Uuid,)>("SELECT id FROM orgs WHERE name = $1") + .bind(&slug) + .fetch_optional(&state.pool) + .await? + .ok_or_else(|| AppError::NotFound(format!("Organization '{slug}' not found")))?; + + let org_id = org_row.0; + + let config_row = + sqlx::query_as::<_, (bool,)>("SELECT enforce FROM org_sso_configs WHERE org_id = $1") + .bind(org_id) + .fetch_optional(&state.pool) + .await?; + + match config_row { + Some((enforce,)) => Ok(Json(SsoStatusResponse { + sso_enabled: true, + enforce, + })), + None => Ok(Json(SsoStatusResponse { + sso_enabled: false, + enforce: false, + })), + } +} + +// --- Get SSO Config (OrgAuth, admin+) --- + +#[derive(Serialize)] +pub struct SsoConfigResponse { + pub issuer_url: String, + pub client_id: String, + pub client_secret_set: bool, + pub allowed_domains: Vec, + pub enforce: bool, + pub auto_provision: bool, + pub default_role: String, + pub linked_users: i64, +} + +pub async fn get_sso_config( + State(state): State, + auth: OrgAuth, +) -> Result, AppError> { + error::require_permission(&state.extensions, &auth.role, Permission::OrgSettingsManage)?; + + if !state.extensions.features.sso { + return Err(AppError::Forbidden( + "SSO is not available in this edition".into(), + )); + } + + let row = sqlx::query_as::<_, (String, String, Vec, bool, bool, String)>( + "SELECT issuer_url, client_id, allowed_domains, enforce, auto_provision, default_role + FROM org_sso_configs WHERE org_id = $1", + ) + .bind(auth.org_id) + .fetch_optional(&state.pool) + .await? + .ok_or_else(|| AppError::NotFound("SSO is not configured for this organization".into()))?; + + let (issuer_url, client_id, allowed_domains, enforce, auto_provision, default_role) = row; + + let linked_users: (i64,) = + sqlx::query_as("SELECT COUNT(*) FROM user_sso_links WHERE org_id = $1") + .bind(auth.org_id) + .fetch_one(&state.pool) + .await?; + + Ok(Json(SsoConfigResponse { + issuer_url, + client_id, + client_secret_set: true, + allowed_domains, + enforce, + auto_provision, + default_role, + linked_users: linked_users.0, + })) +} + +// --- Upsert SSO Config (OrgAuth, owner only) --- + +#[derive(Deserialize)] +pub struct UpsertSsoConfigRequest { + pub issuer_url: String, + pub client_id: String, + pub client_secret: Option, + pub allowed_domains: Vec, + pub enforce: bool, + pub auto_provision: bool, + pub default_role: String, +} + +pub async fn upsert_sso_config( + State(state): State, + auth: OrgAuth, + Json(req): Json, +) -> Result { + if auth.role != "owner" { + return Err(AppError::Forbidden("Requires owner role".into())); + } + + if !state.extensions.features.sso { + return Err(AppError::Forbidden( + "SSO is not available in this edition".into(), + )); + } + + // Normalize domains to lowercase + let allowed_domains: Vec = req + .allowed_domains + .iter() + .map(|d| d.to_lowercase()) + .collect(); + + // Determine the encrypted secret to store + let (secret_encrypted, secret_nonce) = if let Some(ref secret) = req.client_secret { + // Encrypt the provided secret + let (ct, nonce) = state + .extensions + .encryption + .encrypt(secret) + .map_err(|e| AppError::Internal(format!("Encryption error: {e}")))?; + (ct, nonce) + } else { + // Reuse existing secret if it exists, otherwise error + let existing = sqlx::query_as::<_, (String, String)>( + "SELECT client_secret_encrypted, client_secret_nonce FROM org_sso_configs WHERE org_id = $1", + ) + .bind(auth.org_id) + .fetch_optional(&state.pool) + .await?; + + match existing { + Some((ct, nonce)) => (ct, nonce), + None => { + return Err(AppError::BadRequest( + "client_secret is required when creating a new SSO configuration".into(), + )) + } + } + }; + + sqlx::query( + "INSERT INTO org_sso_configs + (org_id, issuer_url, client_id, client_secret_encrypted, client_secret_nonce, + allowed_domains, enforce, auto_provision, default_role) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (org_id) DO UPDATE SET + issuer_url = EXCLUDED.issuer_url, + client_id = EXCLUDED.client_id, + client_secret_encrypted = EXCLUDED.client_secret_encrypted, + client_secret_nonce = EXCLUDED.client_secret_nonce, + allowed_domains = EXCLUDED.allowed_domains, + enforce = EXCLUDED.enforce, + auto_provision = EXCLUDED.auto_provision, + default_role = EXCLUDED.default_role, + updated_at = NOW()", + ) + .bind(auth.org_id) + .bind(&req.issuer_url) + .bind(&req.client_id) + .bind(&secret_encrypted) + .bind(&secret_nonce) + .bind(&allowed_domains) + .bind(req.enforce) + .bind(req.auto_provision) + .bind(&req.default_role) + .execute(&state.pool) + .await?; + + crate::audit::log( + &state.pool, + crate::audit::user_action( + auth.org_id, + auth.user_id, + "sso.config.update", + "sso_config", + Some(auth.org_id), + Some(serde_json::json!({ + "issuer_url": &req.issuer_url, + "client_id": &req.client_id, + "allowed_domains": &allowed_domains, + "enforce": req.enforce, + "auto_provision": req.auto_provision, + "default_role": &req.default_role, + })), + ), + ) + .await; + + Ok(StatusCode::NO_CONTENT) +} + +// --- Delete SSO Config (OrgAuth, owner only) --- + +#[derive(Serialize)] +pub struct DeleteSsoResponse { + pub affected_passwordless_users: i64, +} + +pub async fn delete_sso_config( + State(state): State, + auth: OrgAuth, +) -> Result, AppError> { + if auth.role != "owner" { + return Err(AppError::Forbidden("Requires owner role".into())); + } + + // Count passwordless users that have SSO links in this org + let affected: (i64,) = sqlx::query_as( + "SELECT COUNT(DISTINCT usl.user_id) + FROM user_sso_links usl + JOIN users u ON u.id = usl.user_id + WHERE usl.org_id = $1 AND u.password_hash IS NULL", + ) + .bind(auth.org_id) + .fetch_one(&state.pool) + .await?; + + sqlx::query("DELETE FROM org_sso_configs WHERE org_id = $1") + .bind(auth.org_id) + .execute(&state.pool) + .await?; + + crate::audit::log( + &state.pool, + crate::audit::user_action( + auth.org_id, + auth.user_id, + "sso.config.delete", + "sso_config", + Some(auth.org_id), + Some(serde_json::json!({ + "affected_passwordless_users": affected.0, + })), + ), + ) + .await; + + Ok(Json(DeleteSsoResponse { + affected_passwordless_users: affected.0, + })) +} + +// --- SSO Initiate (public, no auth) --- + +pub async fn sso_initiate( + State(state): State, + axum::extract::Path(slug): axum::extract::Path, +) -> Result { + if !state.extensions.sso.is_enabled() { + return Err(AppError::Forbidden( + "SSO is not available in this edition".into(), + )); + } + + let org_row = sqlx::query_as::<_, (Uuid,)>("SELECT id FROM orgs WHERE name = $1") + .bind(&slug) + .fetch_optional(&state.pool) + .await? + .ok_or_else(|| AppError::NotFound(format!("Organization '{slug}' not found")))?; + + let org_id = org_row.0; + + let config_row = sqlx::query_as::<_, (String, String, String, String)>( + "SELECT issuer_url, client_id, client_secret_encrypted, client_secret_nonce + FROM org_sso_configs WHERE org_id = $1", + ) + .bind(org_id) + .fetch_optional(&state.pool) + .await? + .ok_or_else(|| AppError::NotFound("SSO is not configured for this organization".into()))?; + + let (issuer_url, client_id, secret_encrypted, secret_nonce) = config_row; + + let client_secret = state + .extensions + .encryption + .decrypt(&secret_encrypted, &secret_nonce) + .map_err(|e| AppError::Internal(format!("Failed to decrypt SSO secret: {e}")))?; + + let csrf_state = crate::auth::generate_device_token(); + + let expires_at = chrono::Utc::now() + chrono::Duration::minutes(10); + sqlx::query("INSERT INTO sso_auth_requests (org_id, state, expires_at) VALUES ($1, $2, $3)") + .bind(org_id) + .bind(&csrf_state) + .bind(expires_at) + .execute(&state.pool) + .await?; + + let redirect_uri = format!("{}/api/v1/auth/sso/{}/callback", state.cors_origin, slug); + + let auth_url = state + .extensions + .sso + .authorization_url( + &issuer_url, + &client_id, + &client_secret, + &redirect_uri, + &csrf_state, + ) + .await + .map_err(|e| AppError::Internal(format!("Failed to build SSO authorization URL: {e}")))?; + + Ok(Redirect::to(&auth_url)) +} + +// --- SSO Callback (public, no auth) --- + +#[derive(Deserialize)] +pub struct SsoCallbackQuery { + pub code: String, + pub state: String, +} + +pub async fn sso_callback( + State(state): State, + axum::extract::Path(slug): axum::extract::Path, + axum::extract::Query(query): axum::extract::Query, +) -> Result { + // Validate CSRF state and get org_id + let auth_req = sqlx::query_as::<_, (Uuid,)>( + "DELETE FROM sso_auth_requests WHERE state = $1 AND expires_at > NOW() RETURNING org_id", + ) + .bind(&query.state) + .fetch_optional(&state.pool) + .await? + .ok_or_else(|| AppError::BadRequest("Invalid or expired SSO state".into()))?; + + let org_id = auth_req.0; + + // Verify slug matches the org from the state + let org_slug: (String,) = sqlx::query_as("SELECT name FROM orgs WHERE id = $1") + .bind(org_id) + .fetch_one(&state.pool) + .await?; + + if org_slug.0 != slug { + return Err(AppError::BadRequest( + "SSO state does not match organization".into(), + )); + } + + let config_row = sqlx::query_as::< + _, + ( + String, + String, + String, + String, + Vec, + bool, + bool, + String, + ), + >( + "SELECT issuer_url, client_id, client_secret_encrypted, client_secret_nonce, + allowed_domains, enforce, auto_provision, default_role + FROM org_sso_configs WHERE org_id = $1", + ) + .bind(org_id) + .fetch_optional(&state.pool) + .await? + .ok_or_else(|| AppError::NotFound("SSO is not configured for this organization".into()))?; + + let ( + issuer_url, + client_id, + secret_encrypted, + secret_nonce, + allowed_domains, + _enforce, + auto_provision, + default_role, + ) = config_row; + + let client_secret = state + .extensions + .encryption + .decrypt(&secret_encrypted, &secret_nonce) + .map_err(|e| AppError::Internal(format!("Failed to decrypt SSO secret: {e}")))?; + + let redirect_uri = format!("{}/api/v1/auth/sso/{}/callback", state.cors_origin, slug); + + let user_info = state + .extensions + .sso + .exchange_code( + &issuer_url, + &client_id, + &client_secret, + &redirect_uri, + &query.code, + ) + .await + .map_err(|e| AppError::Internal(format!("SSO code exchange failed: {e}")))?; + + // Validate email domain + if !allowed_domains.is_empty() { + let email_lower = user_info.email.to_lowercase(); + let domain = email_lower.split('@').nth(1).unwrap_or(""); + let domain_allowed = allowed_domains.iter().any(|d| d.to_lowercase() == domain); + if !domain_allowed { + let error_msg = urlencoding::encode( + "Email domain is not allowed for SSO login to this organization", + ); + let login_url = format!("{}/auth/login?error={}", state.cors_origin, error_msg); + return Ok(Redirect::to(&login_url)); + } + } + + let user_id = resolve_sso_user( + &state, + org_id, + &user_info, + &issuer_url, + auto_provision, + &default_role, + ) + .await?; + + // Create auth session + let (raw_token, token_hash) = crate::auth::generate_session_token(); + let session_expires = chrono::Utc::now() + chrono::Duration::days(30); + + sqlx::query("INSERT INTO auth_sessions (user_id, token_hash, expires_at) VALUES ($1, $2, $3)") + .bind(user_id) + .bind(&token_hash) + .bind(session_expires) + .execute(&state.pool) + .await?; + + crate::audit::log( + &state.pool, + crate::audit::user_action( + org_id, + user_id, + "sso.login", + "user", + Some(user_id), + Some(serde_json::json!({ + "email": &user_info.email, + "org": &slug, + })), + ), + ) + .await; + + let redirect_url = format!( + "{}/auth/sso-complete#token={}&org={}", + state.cors_origin, + urlencoding::encode(&raw_token), + urlencoding::encode(&slug), + ); + + Ok(Redirect::to(&redirect_url)) +} + +// --- Resolve SSO User (private helper) --- + +async fn resolve_sso_user( + state: &AppState, + org_id: Uuid, + user_info: &crate::extensions::SsoUserInfo, + issuer_url: &str, + auto_provision: bool, + default_role: &str, +) -> Result { + // 1. Check for existing SSO link by (org_id, subject) + let linked = sqlx::query_as::<_, (Uuid,)>( + "SELECT user_id FROM user_sso_links WHERE org_id = $1 AND subject = $2", + ) + .bind(org_id) + .bind(&user_info.subject) + .fetch_optional(&state.pool) + .await?; + + if let Some((user_id,)) = linked { + // Ensure membership exists (could have been removed) + sqlx::query( + "INSERT INTO user_org_memberships (user_id, org_id, role) VALUES ($1, $2, $3) + ON CONFLICT (user_id, org_id) DO NOTHING", + ) + .bind(user_id) + .bind(org_id) + .bind(default_role) + .execute(&state.pool) + .await?; + return Ok(user_id); + } + + // 2. Check for existing user by email + let existing_user = sqlx::query_as::<_, (Uuid,)>("SELECT id FROM users WHERE email = $1") + .bind(&user_info.email) + .fetch_optional(&state.pool) + .await?; + + if let Some((user_id,)) = existing_user { + // Ensure membership + sqlx::query( + "INSERT INTO user_org_memberships (user_id, org_id, role) VALUES ($1, $2, $3) + ON CONFLICT (user_id, org_id) DO NOTHING", + ) + .bind(user_id) + .bind(org_id) + .bind(default_role) + .execute(&state.pool) + .await?; + + // Create SSO link + sqlx::query( + "INSERT INTO user_sso_links (user_id, org_id, issuer, subject) VALUES ($1, $2, $3, $4) + ON CONFLICT (org_id, subject) DO NOTHING", + ) + .bind(user_id) + .bind(org_id) + .bind(issuer_url) + .bind(&user_info.subject) + .execute(&state.pool) + .await?; + + crate::audit::log( + &state.pool, + crate::audit::user_action( + org_id, + user_id, + "sso.link", + "user", + Some(user_id), + Some(serde_json::json!({ + "email": &user_info.email, + "issuer": issuer_url, + })), + ), + ) + .await; + + return Ok(user_id); + } + + // 3. No user found — auto-provision if enabled + if !auto_provision { + return Err(AppError::Forbidden( + "No account found for this email address. Contact your organization administrator." + .into(), + )); + } + + // Create user with NULL password_hash (SSO-only) + let user_id: Uuid = match sqlx::query_scalar( + "INSERT INTO users (email, password_hash, name) VALUES ($1, NULL, $2) RETURNING id", + ) + .bind(&user_info.email) + .bind(&user_info.name) + .fetch_one(&state.pool) + .await + { + Ok(id) => id, + Err(e) => { + // Handle race condition on users.email UNIQUE constraint + if e.to_string().contains("unique") || e.to_string().contains("duplicate") { + // Retry as lookup + sqlx::query_as::<_, (Uuid,)>("SELECT id FROM users WHERE email = $1") + .bind(&user_info.email) + .fetch_one(&state.pool) + .await + .map(|(id,)| id)? + } else { + return Err(AppError::Sqlx(e)); + } + } + }; + + sqlx::query( + "INSERT INTO user_org_memberships (user_id, org_id, role) VALUES ($1, $2, $3) + ON CONFLICT (user_id, org_id) DO NOTHING", + ) + .bind(user_id) + .bind(org_id) + .bind(default_role) + .execute(&state.pool) + .await?; + + sqlx::query( + "INSERT INTO user_sso_links (user_id, org_id, issuer, subject) VALUES ($1, $2, $3, $4) + ON CONFLICT (org_id, subject) DO NOTHING", + ) + .bind(user_id) + .bind(org_id) + .bind(issuer_url) + .bind(&user_info.subject) + .execute(&state.pool) + .await?; + + crate::audit::log( + &state.pool, + crate::audit::user_action( + org_id, + user_id, + "sso.provision", + "user", + Some(user_id), + Some(serde_json::json!({ + "email": &user_info.email, + "issuer": issuer_url, + "default_role": default_role, + })), + ), + ) + .await; + + Ok(user_id) +} diff --git a/crates/tracevault-server/src/extensions.rs b/crates/tracevault-server/src/extensions.rs index 98299e3..fb533ad 100644 --- a/crates/tracevault-server/src/extensions.rs +++ b/crates/tracevault-server/src/extensions.rs @@ -112,6 +112,37 @@ impl PermissionsProvider for CommunityPermissionsProvider { } } +pub struct CommunitySsoProvider; + +#[async_trait] +impl SsoProvider for CommunitySsoProvider { + fn is_enabled(&self) -> bool { + false + } + + async fn authorization_url( + &self, + _issuer_url: &str, + _client_id: &str, + _client_secret: &str, + _redirect_uri: &str, + _state: &str, + ) -> Result { + Err("SSO is an enterprise feature".into()) + } + + async fn exchange_code( + &self, + _issuer_url: &str, + _client_id: &str, + _client_secret: &str, + _redirect_uri: &str, + _code: &str, + ) -> Result { + Err("SSO is an enterprise feature".into()) + } +} + // -- Adapter implementations (wrapping existing services) -- pub struct FullEncryptionProvider { @@ -226,5 +257,6 @@ pub fn community_registry() -> ExtensionRegistry { pricing: Arc::new(CommunityPricingProvider), compliance: Arc::new(CommunityComplianceProvider), permissions: Arc::new(CommunityPermissionsProvider), + sso: Arc::new(CommunitySsoProvider), } } diff --git a/crates/tracevault-server/src/main.rs b/crates/tracevault-server/src/main.rs index fa870fb..d06ed0f 100644 --- a/crates/tracevault-server/src/main.rs +++ b/crates/tracevault-server/src/main.rs @@ -134,6 +134,20 @@ async fn main() { }); } + // Background cleanup of expired SSO auth requests (every hour) + { + let pool = pool.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(3600)); + loop { + interval.tick().await; + let _ = sqlx::query("DELETE FROM sso_auth_requests WHERE expires_at < NOW()") + .execute(&pool) + .await; + } + }); + } + let embedding_service: Option< std::sync::Arc, > = if extensions.features.chat_search { @@ -176,6 +190,12 @@ async fn main() { post(api::auth::request_invitation), ) .route("/api/v1/github/webhook", post(api::github::webhook)) + .route("/api/v1/auth/sso-status/{slug}", get(api::sso::sso_status)) + .route("/api/v1/auth/sso/{slug}", get(api::sso::sso_initiate)) + .route( + "/api/v1/auth/sso/{slug}/callback", + get(api::sso::sso_callback), + ) .route( "/api/v1/invite/{token}", get(api::invites::get_invite_details), @@ -249,6 +269,13 @@ async fn main() { "/api/v1/orgs/{slug}/chat-settings", get(api::orgs::get_chat_settings).put(api::orgs::update_chat_settings), ) + // Org-scoped: SSO + .route( + "/api/v1/orgs/{slug}/sso", + get(api::sso::get_sso_config) + .put(api::sso::upsert_sso_config) + .delete(api::sso::delete_sso_config), + ) // Org-scoped: repos .route( "/api/v1/orgs/{slug}/repos", diff --git a/enterprise b/enterprise index e46420f..07e2059 160000 --- a/enterprise +++ b/enterprise @@ -1 +1 @@ -Subproject commit e46420f93de26279ead4b3ea5c314f15348d9167 +Subproject commit 07e2059645e610343818108598b61ce56300bf6a diff --git a/web/src/lib/components/sidebar/SidebarNav.svelte b/web/src/lib/components/sidebar/SidebarNav.svelte index 46cf607..f859126 100644 --- a/web/src/lib/components/sidebar/SidebarNav.svelte +++ b/web/src/lib/components/sidebar/SidebarNav.svelte @@ -58,6 +58,9 @@ { href: `/orgs/${slug}/settings/llm`, label: 'Stories LLM' }, ...($features.chat_search ? [{ href: `/orgs/${slug}/settings/chat`, label: 'Chat LLM' }] + : []), + ...($features.sso + ? [{ href: `/orgs/${slug}/settings/sso`, label: 'SSO' }] : []) ]); diff --git a/web/src/routes/auth/login/+page.svelte b/web/src/routes/auth/login/+page.svelte index 9cbdbf3..ab548c4 100644 --- a/web/src/routes/auth/login/+page.svelte +++ b/web/src/routes/auth/login/+page.svelte @@ -16,7 +16,20 @@ let loading = $state(false); let ready = $state(false); + // SSO state + let orgSlug = $state(''); + let ssoEnabled = $state(false); + let ssoEnforce = $state(false); + let ssoChecking = $state(false); + let ssoCheckTimeout: ReturnType | null = null; + onMount(async () => { + const params = new URLSearchParams(window.location.search); + const urlError = params.get('error'); + if (urlError) { + error = urlError; + } + try { const feat = await api.get<{ initialized: boolean }>('/api/v1/features'); if (!feat.initialized) { @@ -29,6 +42,36 @@ ready = true; }); + function onOrgInput() { + const val = orgSlug.trim().toLowerCase(); + if (ssoCheckTimeout) clearTimeout(ssoCheckTimeout); + ssoEnabled = false; + ssoEnforce = false; + if (val.length < 2) return; + ssoChecking = true; + ssoCheckTimeout = setTimeout(() => checkSsoStatus(val), 500); + } + + async function checkSsoStatus(slug: string) { + try { + const resp = await api.get<{ sso_enabled: boolean; enforce: boolean }>( + `/api/v1/auth/sso-status/${encodeURIComponent(slug)}` + ); + ssoEnabled = resp.sso_enabled; + ssoEnforce = resp.enforce; + } catch { + ssoEnabled = false; + ssoEnforce = false; + } finally { + ssoChecking = false; + } + } + + function handleSsoLogin() { + const slug = orgSlug.trim().toLowerCase(); + window.location.href = `/api/v1/auth/sso/${encodeURIComponent(slug)}`; + } + async function handleSubmit(e: Event) { e.preventDefault(); error = ''; @@ -69,7 +112,7 @@ Log in to TraceVault - Enter your email and password to continue. + Enter your credentials to continue. {#if error} @@ -78,19 +121,60 @@ {error} {/if} -
-
- - -
-
- - + + +
+ + + {#if ssoChecking} +

Checking SSO...

+ {/if} +
+ + {#if ssoEnabled} +
+
- - + {/if} + + {#if ssoEnabled && ssoEnforce} +

+ This organization requires SSO login. +

+ {:else} + {#if ssoEnabled} +
+
+ +
+
+ or +
+
+ {/if} + +
+
+ + +
+
+ + +
+ +
+ {/if}

diff --git a/web/src/routes/auth/sso-complete/+page.svelte b/web/src/routes/auth/sso-complete/+page.svelte new file mode 100644 index 0000000..d82a436 --- /dev/null +++ b/web/src/routes/auth/sso-complete/+page.svelte @@ -0,0 +1,33 @@ + + + + Completing sign-in... - TraceVault + + +

+

Completing sign-in...

+
diff --git a/web/src/routes/orgs/[slug]/settings/org/+page.svelte b/web/src/routes/orgs/[slug]/settings/org/+page.svelte index 405d3df..938f462 100644 --- a/web/src/routes/orgs/[slug]/settings/org/+page.svelte +++ b/web/src/routes/orgs/[slug]/settings/org/+page.svelte @@ -3,6 +3,7 @@ import { page } from '$app/stores'; import { api } from '$lib/api'; import { orgStore } from '$lib/stores/org'; + import { features } from '$lib/stores/features'; import { Button } from '$lib/components/ui/button/index.js'; import { Input } from '$lib/components/ui/input/index.js'; import { Label } from '$lib/components/ui/label/index.js'; @@ -73,6 +74,9 @@ General Members API Keys + {#if $features.sso} + SSO + {/if}
{#if error} diff --git a/web/src/routes/orgs/[slug]/settings/sso/+page.svelte b/web/src/routes/orgs/[slug]/settings/sso/+page.svelte new file mode 100644 index 0000000..871b247 --- /dev/null +++ b/web/src/routes/orgs/[slug]/settings/sso/+page.svelte @@ -0,0 +1,255 @@ + + + + {slug} - SSO Settings - TraceVault + + +
+
+ Organizations + / +

SSO Configuration

+
+ + + + {#if !featureFlags.sso} + + {:else if loading} +
+ + Loading... +
+ {:else} + {#if error} + + Error + {error} + + {/if} + + {#if success} + + Success + {success} + + {/if} + +
+
+ OIDC Single Sign-On + {#if config} + + Active — {config.linked_users} linked user{config.linked_users === 1 ? '' : 's'} + + {/if} +
+
+ {#if !isOwner} +

Only organization owners can configure SSO.

+ {:else} +
{ e.preventDefault(); handleSave(); }} class="space-y-4"> +
+ + +

The OIDC issuer URL of your identity provider.

+
+
+ + +
+
+ + + {#if config} +

Leave empty to keep the existing secret.

+ {/if} +
+
+ + +

Comma-separated list of email domains allowed for SSO login.

+
+
+ + +
+
+ + +
+
+ + +
+
+ + {#if config} + + {/if} +
+
+ {/if} +
+
+ {/if} +