diff --git a/.gitignore b/.gitignore index 59ab268..966e7aa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /target /db.sqlite /data +/config.toml diff --git a/Cargo.lock b/Cargo.lock index efab439..ed2e9f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,7 +29,7 @@ dependencies = [ "actix-rt", "actix-service", "actix-utils", - "base64", + "base64 0.22.1", "bitflags", "brotli", "bytes", @@ -65,7 +65,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb" dependencies = [ "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -183,7 +183,7 @@ dependencies = [ "actix-router", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -301,7 +301,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -344,6 +344,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[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" @@ -440,6 +446,15 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -718,7 +733,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -727,8 +742,18 @@ version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[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]] @@ -742,7 +767,20 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.117", +] + +[[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 2.0.117", ] [[package]] @@ -751,9 +789,20 @@ version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ - "darling_core", + "darling_core 0.21.3", + "quote", + "syn 2.0.117", +] + +[[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", + "syn 2.0.117", ] [[package]] @@ -774,6 +823,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", + "serde_core", ] [[package]] @@ -795,7 +845,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn", + "syn 2.0.117", "unicode-xid", ] @@ -842,7 +892,7 @@ dependencies = [ "dsl_auto_type", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -862,7 +912,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe2444076b48641147115697648dc743c2c00b61adade0f01ce67133c7babe8c" dependencies = [ - "syn", + "syn 2.0.117", ] [[package]] @@ -897,7 +947,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -912,12 +962,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd122633e4bef06db27737f21d3738fb89c8f6d5360d6d9d7635dda142a7757e" dependencies = [ - "darling", + "darling 0.21.3", "either", "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -926,6 +976,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[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" @@ -1045,6 +1101,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "ff" version = "0.13.1" @@ -1095,6 +1157,21 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1226,7 +1303,7 @@ dependencies = [ "futures-sink", "futures-util", "http 0.2.12", - "indexmap", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", @@ -1245,13 +1322,19 @@ dependencies = [ "futures-core", "futures-sink", "http 1.4.0", - "indexmap", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", "tracing", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.15.5" @@ -1282,6 +1365,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hkdf" version = "0.12.4" @@ -1408,6 +1497,23 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", ] [[package]] @@ -1416,7 +1522,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -1580,6 +1686,17 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" +[[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.14.0" @@ -1614,6 +1731,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" @@ -1650,7 +1776,7 @@ checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1665,7 +1791,7 @@ dependencies = [ "jni-sys", "log", "simd_cesu8", - "thiserror", + "thiserror 2.0.18", "walkdir", "windows-link", ] @@ -1680,7 +1806,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn", + "syn 2.0.117", ] [[package]] @@ -1699,7 +1825,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1730,7 +1856,7 @@ version = "10.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1" dependencies = [ - "base64", + "base64 0.22.1", "ed25519-dalek", "getrandom 0.2.17", "hmac 0.12.1", @@ -1805,14 +1931,16 @@ dependencies = [ "diesel_migrations", "env_logger", "hmac 0.13.0", - "itertools", + "itertools 0.14.0", "jsonwebtoken", "libsqlite3-sys", "log", + "openidconnect", + "reqwest 0.12.28", "serde", "serde_json", "sha2 0.11.0", - "thiserror", + "thiserror 2.0.18", "url", "utoipa", "utoipa-actix-web", @@ -1832,6 +1960,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -1941,6 +2075,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -2003,6 +2154,26 @@ dependencies = [ "libm", ] +[[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 1.4.0", + "rand 0.8.6", + "reqwest 0.12.28", + "serde", + "serde_json", + "serde_path_to_error", + "sha2 0.10.9", + "thiserror 1.0.69", + "url", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -2015,12 +2186,89 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[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 0.12.1", + "http 1.4.0", + "itertools 0.10.5", + "log", + "oauth2", + "p256", + "p384", + "rand 0.8.6", + "rsa", + "serde", + "serde-value", + "serde_json", + "serde_path_to_error", + "serde_plain", + "serde_with", + "sha2 0.10.9", + "subtle", + "thiserror 1.0.69", + "url", +] + +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[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 = "p256" version = "0.13.2" @@ -2091,7 +2339,7 @@ version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64", + "base64 0.22.1", "serde_core", ] @@ -2189,7 +2437,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56201207dac53e2f38e848e31b4b91616a6bb6e0c7205b77718994a7f49e70fc" dependencies = [ - "base64", + "base64 0.22.1", "byteorder", "bytes", "fallible-iterator", @@ -2243,7 +2491,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -2278,7 +2526,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2 0.6.3", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -2300,7 +2548,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -2435,6 +2683,26 @@ dependencies = [ "bitflags", ] +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "regex" version = "1.12.3" @@ -2470,13 +2738,57 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "h2 0.4.13", + "http 1.4.0", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + [[package]] name = "reqwest" version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-core", @@ -2568,7 +2880,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" dependencies = [ "hashbrown 0.16.1", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -2586,6 +2898,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + [[package]] name = "rustls" version = "0.23.40" @@ -2594,6 +2919,7 @@ checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "aws-lc-rs", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -2691,6 +3017,30 @@ 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 = "scoped-futures" version = "0.1.4" @@ -2770,6 +3120,16 @@ dependencies = [ "serde_core", ] +[[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" @@ -2787,7 +3147,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2803,6 +3163,26 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "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_spanned" version = "1.1.1" @@ -2824,6 +3204,38 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "sha1" version = "0.11.0" @@ -2913,7 +3325,7 @@ checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" dependencies = [ "num-bigint", "num-traits", - "thiserror", + "thiserror 2.0.18", "time", ] @@ -3023,6 +3435,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -3040,7 +3463,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3064,13 +3487,46 @@ dependencies = [ "libc", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.52.0", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -3081,7 +3537,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3156,6 +3612,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-postgres" version = "0.7.15" @@ -3323,7 +3789,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3402,6 +3868,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -3422,7 +3889,7 @@ version = "5.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993" dependencies = [ - "indexmap", + "indexmap 2.14.0", "serde", "serde_json", "utoipa-gen", @@ -3448,7 +3915,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn", + "syn 2.0.117", ] [[package]] @@ -3578,7 +4045,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -3608,7 +4075,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.14.0", "wasm-encoder", "wasmparser", ] @@ -3621,7 +4088,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.14.0", "semver", ] @@ -3654,6 +4121,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "whoami" version = "1.6.1" @@ -3695,7 +4171,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3706,7 +4182,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3875,9 +4351,9 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap", + "indexmap 2.14.0", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -3893,7 +4369,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -3906,7 +4382,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags", - "indexmap", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -3925,7 +4401,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.14.0", "log", "semver", "serde", @@ -3960,7 +4436,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -3969,7 +4445,7 @@ name = "ytrss" version = "0.1.0" dependencies = [ "chrono", - "reqwest", + "reqwest 0.13.3", "roxmltree", "serde", "serde-roxmltree", @@ -3992,7 +4468,7 @@ checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4012,7 +4488,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -4052,7 +4528,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 368c594..f16fe39 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,7 @@ diesel = { version = "2.3" } diesel-async = { version = "0.8", features = [ "async-connection-wrapper", "bb8", - "migrations" + "migrations", ] } diesel_migrations = { version = "2.3" } uuid = { version = "1", features = ["serde", "v7"] } @@ -39,11 +39,13 @@ log = "0.4" serde = { version = "1", features = ["derive"] } serde_json = { version = "1" } -# Password hashing and username anonymization +# Authentication argon2 = { version = "0.5.3", features = ["std"] } hmac = "0.13" jsonwebtoken = { version = "10.3.0", features = ["rust_crypto"] } sha2 = "0.11.0" +openidconnect = { version = "4.0", features = ["reqwest"] } +reqwest = "0.12" # version 0.12 is required by oauth2 crate used by openidconnect # YouTube scraping ytrss = { path = "ytrss" } @@ -51,7 +53,7 @@ ytrss = { path = "ytrss" } # Helper crates config = { version = "0.15.22", default-features = false, features = [ "convert-case", - "toml" + "toml", ] } itertools = "0.14" libsqlite3-sys = { version = "0.37.0", features = ["bundled"] } diff --git a/Dockerfile b/Dockerfile index 7be5015..45c5dab 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ RUN cargo chef prepare --recipe-path recipe.json FROM chef AS builder COPY --from=planner /app/recipe.json recipe.json -RUN apk add sqlite-static sqlite-dev +RUN apk add sqlite-static sqlite-dev openssl-libs-static openssl-dev RUN cargo chef cook --release --recipe-path recipe.json # Build application diff --git a/README.md b/README.md index 85956c0..aaf7afe 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ There are two ways to configure `sync-server` The configuration can also be done through environment variables. Casing doesn't matter here. -### Configuration Reference: +### Configuration Reference | Config option | Description | Default | Example | | ---------------------- | ---------------------------------------------------- | ------- | -------------------- | @@ -33,11 +33,26 @@ There are two ways to configure `sync-server` | `allow_registration` | Whether to allow registering on this server | `true` | `false` | | `validate_submitted_metadata` | Whether to check incoming video data against YouTube | `true` | `false` | +`oidc` section of the configuration (all options are required to use OIDC): +| Config option | Description | Default | Example | +| ---------------------- | ---------------------------------------------------------- | ---------- | ------------------------ | +| `provider_url` | Base URL of the OIDC provider | None | https://auth.example.com | +| `client_id` | Client ID of the OAuth app configured at the OIDC provider | None | SecretOauthAppClientID | +| `client_secret` | Client secret of the OAuth app configured at the OIDC provider | None | SomeVerySecureString64 | +| `app_url` | Public URL to the `sync-server` instance | None | https://sync.example.com | + +The OIDC app must be configured to allow redirects to `/v1/account/oidc/authenticate/callback` and +`/v1/account/oidc/authenticate/delete/callback`. + ## API Documentation - Start the app, e.g. with `cargo run`. - The documentation can now be found at `http://localhost:8080/docs`. ### Authentication +There are two ways to login: +- via username and password, i.e. credentials are stored on the server +- via OpenID Connect, i.e. authentication is delegated to an OIDC server. Only works if you configure the OIDC provider as described in [the configuration reference](configuration-reference) + After registering or logging in, you receive a `jwt` as response. This `jwt` must be passed either as `Authorization` cookie or header for authenticated requests, e.g. for creating subscriptions. @@ -45,7 +60,12 @@ For example: - Header: `Authorization: abcdefghijklmnopqrtuvwxyz` - Cookie: `Authorization=abcdefghijklmnopqrtuvwxyz` -## Developing +## Development +### Running +- Copy `config.dev.toml` to `config.toml`. +- Execute `cargo run`. +- Visit to open the API playground. + ### Adding New Database Objects or Altering Tables + Create a new migration with `diesel migration generate ` + Edit the `up.sql` and `down.sql` files in `migrations/..._`. E.g., add a `SQL CREATE TABLE` statement or alter an existing table by adding a new field. diff --git a/config.dev.toml b/config.dev.toml new file mode 100644 index 0000000..63cc1bb --- /dev/null +++ b/config.dev.toml @@ -0,0 +1,10 @@ +database_url = "./db.sqlite" +secret_key = "changeme" +allow_registration = true +validate_submitted_metadata = true + +[oidc] +provider_url = "http://localhost:9400" +client_id = "changeme" +client_secret = "changeme" +app_url = "http://localhost:8080" diff --git a/config.toml b/config.toml deleted file mode 100644 index 8616ea2..0000000 --- a/config.toml +++ /dev/null @@ -1,4 +0,0 @@ -database_url = "./db.sqlite" -secret_key = "changeme" -allow_registration = true -validate_submitted_metadata = true diff --git a/migrations/2026-05-16-052736-0000_playlists/down.sql b/migrations/2026-05-16-052736-0000_playlists/down.sql index 5013e91..0aceb6a 100644 --- a/migrations/2026-05-16-052736-0000_playlists/down.sql +++ b/migrations/2026-05-16-052736-0000_playlists/down.sql @@ -1,3 +1,3 @@ -- This file should undo anything in `up.sql` -DROP TABLE IF EXISTS `playlist`; -DROP TABLE IF EXISTS `playlist_video_member`; +DROP TABLE IF EXISTS playlist; +DROP TABLE IF EXISTS playlist_video_member; diff --git a/migrations/2026-05-16-052736-0000_playlists/up.sql b/migrations/2026-05-16-052736-0000_playlists/up.sql index c162b02..0e58ef6 100644 --- a/migrations/2026-05-16-052736-0000_playlists/up.sql +++ b/migrations/2026-05-16-052736-0000_playlists/up.sql @@ -1,22 +1,22 @@ -- Your SQL goes here -DROP TABLE IF EXISTS `playlist`; -DROP TABLE IF EXISTS `playlist_video_member`; +DROP TABLE IF EXISTS playlist CASCADE; +DROP TABLE IF EXISTS playlist_video_member CASCADE; -CREATE TABLE `playlist`( - `id` VARCHAR NOT NULL, - `account_id` VARCHAR NOT NULL, - `title` VARCHAR NOT NULL, - `description` VARCHAR NOT NULL, - `thumbnail_url` VARCHAR, - PRIMARY KEY(`id`,`account_id`), - CONSTRAINT FK__playlist__account FOREIGN KEY(`account_id`) REFERENCES `account`(`id`) ON DELETE CASCADE +CREATE TABLE playlist( + id VARCHAR NOT NULL, + account_id VARCHAR NOT NULL, + title VARCHAR NOT NULL, + description VARCHAR NOT NULL, + thumbnail_url VARCHAR, + PRIMARY KEY(id,account_id), + CONSTRAINT FK__playlist__account FOREIGN KEY(account_id) REFERENCES account(id) ON DELETE CASCADE ); -CREATE TABLE `playlist_video_member`( - `account_id` VARCHAR NOT NULL, - `playlist_id` VARCHAR NOT NULL, - `video_id` VARCHAR NOT NULL, - PRIMARY KEY(`account_id`,`playlist_id`, `video_id`), - CONSTRAINT FK__playlist_video_member__account FOREIGN KEY(`account_id`) REFERENCES `account`(`id`) ON DELETE CASCADE, - CONSTRAINT FK__playlist_video_member__playlist FOREIGN KEY(`playlist_id`, `account_id`) REFERENCES `playlist`(`id`, `account_id`) ON DELETE CASCADE, - CONSTRAINT FK__playlist_video_member__video FOREIGN KEY(`video_id`) REFERENCES `video`(`id`) ON DELETE RESTRICT +CREATE TABLE playlist_video_member( + account_id VARCHAR NOT NULL, + playlist_id VARCHAR NOT NULL, + video_id VARCHAR NOT NULL, + PRIMARY KEY(account_id,playlist_id, video_id), + CONSTRAINT FK__playlist_video_member__account FOREIGN KEY(account_id) REFERENCES account(id) ON DELETE CASCADE, + CONSTRAINT FK__playlist_video_member__playlist FOREIGN KEY(playlist_id, account_id) REFERENCES playlist(id, account_id) ON DELETE CASCADE, + CONSTRAINT FK__playlist_video_member__video FOREIGN KEY(video_id) REFERENCES video(id) ON DELETE RESTRICT ); diff --git a/migrations/2026-07-26-080240-0000_add-oidc/down.sql b/migrations/2026-07-26-080240-0000_add-oidc/down.sql new file mode 100644 index 0000000..e5ac080 --- /dev/null +++ b/migrations/2026-07-26-080240-0000_add-oidc/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +ALTER TABLE account DROP COLUMN oidc_sub; diff --git a/migrations/2026-07-26-080240-0000_add-oidc/up.sql b/migrations/2026-07-26-080240-0000_add-oidc/up.sql new file mode 100644 index 0000000..dc4006b --- /dev/null +++ b/migrations/2026-07-26-080240-0000_add-oidc/up.sql @@ -0,0 +1,13 @@ +-- make password hash nullable +-- https://stackoverflow.com/questions/4007014/alter-column-in-sqlite +CREATE TABLE account_temp( + id VARCHAR PRIMARY KEY NOT NULL, + name_hash VARCHAR NOT NULL UNIQUE, + password_hash VARCHAR NULL +); +INSERT INTO account_temp SELECT * FROM account; +DROP TABLE account CASCADE; +ALTER TABLE account_temp RENAME TO account; + +-- add oidc sub +ALTER TABLE account ADD oidc_sub VARCHAR NULL DEFAULT NULL; diff --git a/src/auth.rs b/src/auth.rs index a8e7886..68c0a91 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -65,6 +65,7 @@ pub fn verify_password(password: &str, password_hash: &str) -> bool { } /// Generate HMAC of accountname. Usernames are not stored in plaintext for better anonymity. +/// It's not possible to restore the username even if `secret_key` is known (except for brute-forcing). pub fn hash_accountname(accountname: &str, secret_key: &[u8]) -> String { let mut mac = Hmac::::new_from_slice(accountname.as_bytes()).unwrap(); mac.update(secret_key); diff --git a/src/config.rs b/src/config.rs index 699bd6a..906d0ea 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4,6 +4,14 @@ const fn default_true() -> bool { true } +#[derive(serde::Deserialize, Clone)] +pub struct OidcConfig { + pub provider_url: String, + pub client_id: String, + pub client_secret: String, + pub app_url: String, +} + #[derive(serde::Deserialize, Clone)] pub struct Config { #[serde(rename = "secret_key")] @@ -13,6 +21,8 @@ pub struct Config { #[serde(default = "default_true")] pub validate_submitted_metadata: bool, pub database_url: String, + #[serde(default)] + pub oidc: Option, } pub fn build_config() -> Result { diff --git a/src/database/account.rs b/src/database/account.rs index 2bbd1b3..7990882 100644 --- a/src/database/account.rs +++ b/src/database/account.rs @@ -51,10 +51,21 @@ pub async fn insert_new_account( pub async fn delete_existing_account( conn: &mut DbConnection, account_id: &str, // prevent collision with db column imported inside the function -) -> Result<(), DbError> { - diesel::delete(account.filter(id.eq(account_id.to_string()))) +) -> Result { + let rows_affected = diesel::delete(account.filter(id.eq(account_id.to_string()))) .execute(conn) .await?; - Ok(()) + Ok(rows_affected == 1) +} + +pub async fn delete_existing_account_by_oidc_sub( + conn: &mut DbConnection, + oidc_sub_: &str, // prevent collision with db column imported inside the function +) -> Result { + let rows_affected = diesel::delete(account.filter(oidc_sub.eq(oidc_sub_.to_string()))) + .execute(conn) + .await?; + + Ok(rows_affected == 1) } diff --git a/src/handlers.rs b/src/handlers.rs index 76f4322..c9c171e 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -9,7 +9,7 @@ use actix_web::{ }; use utoipa_actix_web::scope::Scope; -use crate::models::Account; +use crate::{models::Account, oidc::OidcError}; pub mod health; pub mod playlist_bookmarks; @@ -58,6 +58,10 @@ pub enum HandlerError { ValidationErrorWithContext(String), #[error("failed to load data from YouTube")] YouTubeConnectError, + #[error("{0}")] + OidcError(OidcError), + #[error("account doesn't support logging in via password")] + PasswordLoginDisabledForAccount, } impl ResponseError for HandlerError { @@ -82,6 +86,8 @@ impl ResponseError for HandlerError { Self::ValidationError => StatusCode::BAD_REQUEST, Self::ValidationErrorWithContext(_) => StatusCode::BAD_REQUEST, Self::YouTubeConnectError => StatusCode::INTERNAL_SERVER_ERROR, + Self::OidcError(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::PasswordLoginDisabledForAccount => StatusCode::BAD_REQUEST, } } } diff --git a/src/handlers/user.rs b/src/handlers/user.rs index 63594bb..00e115e 100644 --- a/src/handlers/user.rs +++ b/src/handlers/user.rs @@ -1,22 +1,27 @@ use actix_web::body::MessageBody; use actix_web::dev::{ServiceFactory, ServiceRequest, ServiceResponse}; use actix_web::middleware::Next; -use actix_web::{HttpMessage, HttpResponse, Responder, delete, post, web}; +use actix_web::web::Redirect; +use actix_web::{HttpMessage, HttpRequest, HttpResponse, Responder, delete, get, post, web}; use diesel::result::DatabaseErrorKind; +use serde::Deserialize; use utoipa_actix_web::scope; use uuid::Uuid; use crate::auth::{generate_jwt, hash_accountname, hash_password, verify_jwt, verify_password}; use crate::database::account::{ - delete_existing_account, find_account_by_id, find_account_by_name_hash, insert_new_account, + delete_existing_account, delete_existing_account_by_oidc_sub, find_account_by_id, + find_account_by_name_hash, insert_new_account, }; use crate::dto::LoginResponse; use crate::handlers::{HandlerError, HandlerResult, ScopedHandler}; use crate::models::Account; -use crate::{CONFIG, WebData, dto, get_db_conn, models}; +use crate::oidc::check_oidc_auth_request; +use crate::{CONFIG, WebData, dto, get_db_conn, models, oidc}; const AUTH_HEADER_KEY: &str = "Authorization"; const MIN_PASSWORD_LENGTH: usize = 8; +const OIDC_ACCOUNT_PREFIX: &str = "OIDC-ACCOUNT-"; pub struct UserHandler {} impl ScopedHandler for UserHandler { @@ -29,15 +34,24 @@ impl ScopedHandler for UserHandler { Error = actix_web::Error, >, > { - scope::scope("/account") + let mut s = scope::scope("/account") .service(register_account) - .service(login_account) - // services that require auth start here - .service( - scope::scope("") - .wrap(actix_web::middleware::from_fn(auth_middleware)) - .service(delete_account), - ) + .service(login_account); + + if CONFIG.oidc.is_some() { + s = s + .service(authenticate_oidc_account) + .service(authenticate_oidc_account_callback) + .service(delete_oidc_account) + .service(delete_oidc_account_callback) + }; + + // services that require auth start here + s.service( + scope::scope("") + .wrap(actix_web::middleware::from_fn(auth_middleware)) + .service(delete_account), + ) } } @@ -51,6 +65,11 @@ async fn register_account( return Err(HandlerError::RegistrationDisabled); } + // usernames starting with OIDC_ACCOUNT_PREFIX are preserved for oidc users + if form.name.starts_with(OIDC_ACCOUNT_PREFIX) { + return Err(HandlerError::InvalidCredentials); + } + let mut conn = get_db_conn!(pool); let password_length = form.password.len(); @@ -61,7 +80,8 @@ async fn register_account( let account = models::Account { id: Uuid::now_v7().to_string(), name_hash: hash_accountname(&form.name, CONFIG.secret.as_bytes()), - password_hash: hash_password(&form.password), + password_hash: Some(hash_password(&form.password)), + oidc_sub: None, }; let account = insert_new_account(&mut conn, &account) @@ -101,7 +121,11 @@ async fn login_account( return Err(HandlerError::InvalidCredentials); }; - if !verify_password(&form.password, &account.password_hash) { + let Some(password_hash) = &account.password_hash else { + return Err(HandlerError::PasswordLoginDisabledForAccount); + }; + + if !verify_password(&form.password, password_hash) { return Err(HandlerError::InvalidCredentials); } @@ -125,7 +149,11 @@ async fn delete_account( ) -> HandlerResult { let mut conn = get_db_conn!(pool); - if !verify_password(&form.password, &account.password_hash) { + let Some(password_hash) = &account.password_hash else { + return Err(HandlerError::PasswordLoginDisabledForAccount); + }; + + if !verify_password(&form.password, &password_hash) { return Err(HandlerError::InvalidCredentials); } @@ -175,3 +203,139 @@ pub async fn auth_middleware( next.call(req).await } + +#[derive(Deserialize)] +struct OidcAuthenticationRequest { + /// Url to redirect to once authentication succeeded. + /// Passes a `token` query parameter to the URL, which is a valid JWT for the authenticated account. + redirect_url: String, +} + +#[utoipa::path] +#[get("/oidc/authenticate")] +async fn authenticate_oidc_account( + req: HttpRequest, + query: web::Query, +) -> HandlerResult { + let callback_route = req + .url_for::<&[_; 0], &String>("authenticate_oidc_account_callback", &[]) + .unwrap(); + + let redirect_url = oidc::authenticate_oidc_user_request( + &CONFIG.oidc.clone().unwrap(), + callback_route.path(), + query.redirect_url.clone(), + ) + .await + .map_err(HandlerError::OidcError)?; + + Ok(Redirect::to(redirect_url)) +} + +fn oidc_username_hash(oidc_sub: &str) -> String { + // the name is getting hashed anyways, so its actual value isn't important because the user + // never sees it + // it only is important that the username never changes and doesn't conflict with the normally created ones + let username = format!("{OIDC_ACCOUNT_PREFIX}{oidc_sub}"); + hash_accountname(&username, CONFIG.secret.as_bytes()) +} + +#[derive(Deserialize)] +struct OidcCallbackData { + code: String, + state: String, +} + +#[utoipa::path] +#[get("/oidc/authenticate/callback")] +async fn authenticate_oidc_account_callback( + pool: WebData, + query: web::Query, +) -> HandlerResult { + let mut conn = get_db_conn!(pool); + + let (user_claims, redirect_url) = check_oidc_auth_request(&query.state, query.code.clone()) + .await + .map_err(HandlerError::OidcError)?; + + let oidc_sub = user_claims.subject().as_str(); + + let name_hash = oidc_username_hash(oidc_sub); + let account = if let Some(existing_account) = find_account_by_name_hash(&mut conn, &name_hash) + .await + .ok() + .flatten() + { + existing_account + } else { + let account = Account { + id: Uuid::now_v7().to_string(), + name_hash, + // the password_hash field should be nullable instead of using an empty string here, + // but unfortunately SQLite doesn't have a statement to alter table columns... + password_hash: None, + oidc_sub: Some(oidc_sub.to_string()), + }; + insert_new_account(&mut conn, &account) + .await + .map_err(|err| HandlerError::InternalDatabaseErrorWithContext(err.to_string()))?; + + account + }; + + match generate_jwt(&account, CONFIG.secret.as_bytes()) { + Ok(jwt) => Ok(Redirect::to(format!("{redirect_url}?token={jwt}"))), + Err(err) => Err(HandlerError::InternalDatabaseErrorWithContext( + err.to_string(), + )), + } +} + +#[utoipa::path] +#[get("/oidc/delete")] +async fn delete_oidc_account( + req: HttpRequest, + query: web::Query, +) -> HandlerResult { + let callback_route = req + .url_for::<&[_; 0], &String>("delete_oidc_account_callback", &[]) + .unwrap(); + + let redirect_url = oidc::authenticate_oidc_user_request( + &CONFIG.oidc.clone().unwrap(), + callback_route.path(), + query.redirect_url.clone(), + ) + .await + .map_err(HandlerError::OidcError)?; + + Ok(Redirect::to(redirect_url)) +} + +#[utoipa::path] +#[get("/oidc/delete/callback")] +async fn delete_oidc_account_callback( + pool: WebData, + query: web::Query, +) -> HandlerResult { + let mut conn = get_db_conn!(pool); + + let (user_claims, redirect_url) = check_oidc_auth_request(&query.state, query.code.clone()) + .await + .map_err(HandlerError::OidcError)?; + + let oidc_sub = user_claims.subject().as_str(); + + match delete_existing_account_by_oidc_sub(&mut conn, oidc_sub).await { + Ok(deleted) => { + if deleted { + Ok(Redirect::to(redirect_url)) + } else { + Err(HandlerError::AccountNotExists) + } + } + Err(err) => Err(HandlerError::InternalDatabaseErrorWithContext( + err.to_string(), + )), + } +} diff --git a/src/main.rs b/src/main.rs index f86beea..9ee2154 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,6 +17,7 @@ use crate::{ playlists::PlaylistsHandler, subscriptions::SubscriptionsHandler, user::UserHandler, watch_history::WatchHistoryHandler, }, + oidc::init_oidc, openapi::ApiDoc, }; @@ -26,6 +27,7 @@ mod database; mod dto; mod handlers; mod models; +mod oidc; mod openapi; mod schema; mod validation; @@ -65,6 +67,10 @@ async fn main() -> io::Result<()> { // run database migrations (must be done BEFORE the server is started!) run_migrations(&pool).await; + if let Some(oidc) = &CONFIG.oidc { + init_oidc(oidc).await; + } + log::info!("starting HTTP server at http://localhost:8080"); HttpServer::new(move || { diff --git a/src/models.rs b/src/models.rs index 4c46bee..27d33c3 100644 --- a/src/models.rs +++ b/src/models.rs @@ -20,7 +20,8 @@ use super::schema::*; pub struct Account { pub id: String, pub name_hash: String, - pub password_hash: String, + pub password_hash: Option, + pub oidc_sub: Option, } #[derive( diff --git a/src/oidc.rs b/src/oidc.rs new file mode 100644 index 0000000..3bb882c --- /dev/null +++ b/src/oidc.rs @@ -0,0 +1,181 @@ +//! To quickly setup a test server, run +//! ```sh +//! docker run -p 9400:9400 ghcr.io/geigerzaehler/oidc-provider-mock +//! ``` +//! Then in `config.toml`, set `provider_url` to `http://localhost:9400` and +//! `app_url` to `http://localhost:8080`. The other OIDC values don't matter for testing. +//! +//! Library docs: +use std::{ + borrow::Cow, + collections::HashMap, + ops::DerefMut, + sync::{LazyLock, Mutex, OnceLock}, +}; + +use openidconnect::{ + AuthorizationCode, Client, ClientId, ClientSecret, CsrfToken, EmptyAdditionalClaims, + EndpointMaybeSet, EndpointNotSet, EndpointSet, IdTokenClaims, IssuerUrl, Nonce, + PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope, StandardErrorResponse, TokenResponse, + core::{ + CoreAuthDisplay, CoreAuthPrompt, CoreAuthenticationFlow, CoreClient, CoreErrorResponseType, + CoreGenderClaim, CoreJsonWebKey, CoreJweContentEncryptionAlgorithm, CoreProviderMetadata, + CoreRevocableToken, CoreRevocationErrorResponse, CoreTokenIntrospectionResponse, + CoreTokenResponse, + }, +}; + +use crate::config::OidcConfig; + +#[allow(clippy::type_complexity)] +static CHALLENGES_STORE: LazyLock< + Mutex>, +> = LazyLock::new(|| Mutex::new(HashMap::new())); + +static CLIENT: OnceLock = OnceLock::new(); + +// very ugly, copied from https://github.com/ramosbugs/openidconnect-rs/issues/193#issuecomment-2739072936 +pub type ApplicationOidcClient< + HasAuthUrl = EndpointSet, + HasDeviceAuthUrl = EndpointNotSet, + HasIntrospectionUrl = EndpointNotSet, + HasRevocationUrl = EndpointNotSet, + HasTokenUrl = EndpointMaybeSet, + HasUserInfoUrl = EndpointMaybeSet, +> = Client< + EmptyAdditionalClaims, + CoreAuthDisplay, + CoreGenderClaim, + CoreJweContentEncryptionAlgorithm, + CoreJsonWebKey, + CoreAuthPrompt, + StandardErrorResponse, + CoreTokenResponse, + CoreTokenIntrospectionResponse, + CoreRevocableToken, + CoreRevocationErrorResponse, + HasAuthUrl, + HasDeviceAuthUrl, + HasIntrospectionUrl, + HasRevocationUrl, + HasTokenUrl, + HasUserInfoUrl, +>; + +#[derive(thiserror::Error, Debug)] +pub enum OidcError { + #[error("failed to discover oidc endpoint of configured provider_url: {0}")] + DiscoveryFailed(String), + #[error("oidc client not initialized, please check the debug logs")] + OidcNotInitialized, + #[error("malformed provider uri: {0}")] + MalformedProviderUri(String), + #[error("malformed redirect uri: {0}")] + MalformedRedirectUri(String), + #[error("invalid token response: {0}")] + InvalidTokenResponse(String), + #[error("provided session does not exist")] + InvalidSession, +} + +pub async fn init_oidc(cfg: &OidcConfig) { + match build_oidc_client(cfg).await { + Ok(client) => CLIENT.set(client).unwrap(), + Err(err) => eprintln!("{err}"), + } +} + +async fn build_oidc_client(cfg: &OidcConfig) -> Result { + let http_client = reqwest::ClientBuilder::new() + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap(); + + let provider_metadata = CoreProviderMetadata::discover_async( + IssuerUrl::new(cfg.provider_url.clone()) + .map_err(|err| OidcError::MalformedProviderUri(err.to_string()))?, + &http_client, + ) + .await + .map_err(|err| OidcError::DiscoveryFailed(err.to_string()))?; + + Ok(CoreClient::from_provider_metadata( + provider_metadata, + ClientId::new(cfg.client_id.clone()), + Some(ClientSecret::new(cfg.client_secret.clone())), + )) +} + +pub async fn authenticate_oidc_user_request( + cfg: &OidcConfig, + callback_path: &str, + app_state_extra: String, +) -> Result { + let Some(client) = CLIENT.get() else { + return Err(OidcError::OidcNotInitialized); + }; + + let redirect_uri = RedirectUrl::new(format!("{}{}", cfg.app_url, callback_path)) + .map_err(|err| OidcError::MalformedRedirectUri(err.to_string()))?; + + let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); + let (auth_url, state, nonce) = client + .authorize_url( + CoreAuthenticationFlow::AuthorizationCode, + CsrfToken::new_random, + Nonce::new_random, + ) + .add_scope(Scope::new("email".to_string())) + .add_scope(Scope::new("profile".to_string())) + .set_pkce_challenge(pkce_challenge) + .set_redirect_uri(Cow::Borrowed(&redirect_uri)) + .url(); + + CHALLENGES_STORE.lock().unwrap().deref_mut().insert( + state.secret().to_string(), + // we store extra state for the app here that we can restore at + // the callback from the OIDC provider and forwarded back to the app + (pkce_verifier, nonce, redirect_uri, app_state_extra), + ); + + Ok(auth_url.to_string()) +} + +pub async fn check_oidc_auth_request( + state: &str, + code: String, +) -> Result< + ( + IdTokenClaims, + String, + ), + OidcError, +> { + let Some(client) = CLIENT.get() else { + return Err(OidcError::OidcNotInitialized); + }; + + let Some((pkce_verifier, nonce, redirect_uri, app_state_extra)) = + CHALLENGES_STORE.lock().unwrap().remove(state) + else { + return Err(OidcError::InvalidSession); + }; + + let token_response = client + .exchange_code(AuthorizationCode::new(code)) + .map_err(|err| OidcError::InvalidTokenResponse(err.to_string()))? + .set_pkce_verifier(pkce_verifier) + // required as per https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3 although + // the openidconnect-rs example code doesn't contain it... + .set_redirect_uri(Cow::Owned(redirect_uri)) + .request_async(&reqwest::Client::builder().build().unwrap()) + .await + .map_err(|err| OidcError::InvalidTokenResponse(err.to_string()))?; + + let id_token = token_response.id_token().unwrap(); + let claims = id_token + .claims(&client.id_token_verifier(), &nonce) + .map_err(|err| OidcError::InvalidTokenResponse(err.to_string()))?; + + Ok((claims.clone(), app_state_extra)) +} diff --git a/src/schema.rs b/src/schema.rs index 6ca3435..e66f493 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -4,7 +4,8 @@ diesel::table! { account (id) { id -> Text, name_hash -> Text, - password_hash -> Text, + password_hash -> Nullable, + oidc_sub -> Nullable, } } diff --git a/src/validation.rs b/src/validation.rs index f3bdb67..4d24474 100644 --- a/src/validation.rs +++ b/src/validation.rs @@ -26,6 +26,10 @@ const ALLOWED_THUMBNAIL_DOMAINS: [&str; 5] = [ ]; fn verify_image_url(image_url: &str) -> bool { + if image_url.is_empty() { + return true; + } + let Ok(url) = url::Url::parse(image_url) else { return false; };