diff --git a/Cargo.lock b/Cargo.lock index 0ef8745..d28ae67 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -378,12 +378,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" @@ -692,6 +704,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" @@ -715,6 +739,7 @@ dependencies = [ "fiat-crypto", "rustc_version", "subtle", + "zeroize", ] [[package]] @@ -734,8 +759,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.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", ] [[package]] @@ -752,13 +787,38 @@ dependencies = [ "syn", ] +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "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.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", "quote", "syn", ] @@ -814,7 +874,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", @@ -880,12 +940,33 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ + "pkcs8", "signature", ] @@ -897,9 +978,11 @@ checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ "curve25519-dalek", "ed25519", + "serde", "sha2", "signature", "subtle", + "zeroize", ] [[package]] @@ -911,6 +994,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 = "email_address" version = "0.2.9" @@ -1065,6 +1169,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" @@ -1254,6 +1368,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1293,6 +1408,17 @@ dependencies = [ "weezl", ] +[[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.12" @@ -1735,6 +1861,7 @@ checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" dependencies = [ "autocfg", "hashbrown 0.12.3", + "serde", ] [[package]] @@ -1745,6 +1872,8 @@ checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" dependencies = [ "equivalent", "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] @@ -1787,6 +1916,15 @@ dependencies = [ "serde", ] +[[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" @@ -1822,6 +1960,29 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "10.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c76e1c7d7df3e34443b3621b459b066a7b79644f059fc8b2db7070c825fd417e" +dependencies = [ + "base64 0.22.1", + "ed25519-dalek", + "getrandom 0.2.16", + "hmac", + "js-sys", + "p256", + "p384", + "pem", + "rand 0.8.5", + "rsa", + "serde", + "serde_json", + "sha2", + "signature", + "simple_asn1", +] + [[package]] name = "k-core" version = "0.1.10" @@ -2221,18 +2382,24 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "axum-login", "chrono", "futures-core", "futures-util", + "jsonwebtoken", "k-core", "notes-domain", + "openidconnect", + "password-auth", "serde", "serde_json", "sqlx", "thiserror 2.0.17", "tokio", + "tower-sessions", "tower-sessions-sqlx-store", "tracing", + "url", "uuid", ] @@ -2375,6 +2542,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.16", + "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" @@ -2403,6 +2590,37 @@ dependencies = [ "pkg-config", ] +[[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" @@ -2459,6 +2677,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.10" @@ -2484,6 +2711,30 @@ dependencies = [ "ureq 3.1.4", ] +[[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" @@ -2548,6 +2799,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -2674,6 +2935,15 @@ dependencies = [ "zerocopy", ] +[[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-macro-error-attr2" version = "2.0.0" @@ -2741,7 +3011,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools", + "itertools 0.14.0", "proc-macro2", "quote", "syn", @@ -2947,7 +3217,7 @@ dependencies = [ "built", "cfg-if", "interpolate_name", - "itertools", + "itertools 0.14.0", "libc", "libfuzzer-sys", "log", @@ -3004,7 +3274,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f" dependencies = [ "either", - "itertools", + "itertools 0.14.0", "rayon", ] @@ -3047,6 +3317,26 @@ dependencies = [ "thiserror 2.0.17", ] +[[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.2" @@ -3123,6 +3413,16 @@ dependencies = [ "webpki-roots 1.0.4", ] +[[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.52" @@ -3321,12 +3621,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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54e910108742c57a770f492731f99be216a52fadd361b06c8fb59d74ccc267d2" +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 = "2.11.1" @@ -3379,6 +3717,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" @@ -3432,6 +3780,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_repr" version = "0.1.20" @@ -3455,6 +3812,37 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.12.1", + "schemars 0.9.0", + "schemars 1.2.0", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c" +dependencies = [ + "darling 0.21.3", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sha1" version = "0.10.6" @@ -3539,6 +3927,18 @@ dependencies = [ "quote", ] +[[package]] +name = "simple_asn1" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.17", + "time", +] + [[package]] name = "slab" version = "0.4.11" @@ -4065,7 +4465,7 @@ dependencies = [ "derive_builder", "esaxx-rs", "getrandom 0.3.4", - "itertools", + "itertools 0.14.0", "log", "macro_rules_attribute", "monostate", @@ -4630,7 +5030,7 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca" dependencies = [ - "darling", + "darling 0.20.11", "once_cell", "proc-macro-error2", "proc-macro2", diff --git a/notes-domain/src/lib.rs b/notes-domain/src/lib.rs index 0ee58eb..7dc26c9 100644 --- a/notes-domain/src/lib.rs +++ b/notes-domain/src/lib.rs @@ -17,12 +17,9 @@ pub mod services; pub mod value_objects; // Re-export commonly used types at crate root -pub use entities::{MAX_TAGS_PER_NOTE, Note, NoteFilter, NoteVersion, Tag, User}; +pub use entities::*; pub use errors::{DomainError, DomainResult}; -pub use ports::MessageBroker; -pub use repositories::{NoteRepository, TagRepository, UserRepository}; -pub use services::{CreateNoteRequest, NoteService, TagService, UpdateNoteRequest, UserService}; -pub use value_objects::{ - Email, MAX_NOTE_TITLE_LENGTH, MAX_TAG_NAME_LENGTH, MIN_PASSWORD_LENGTH, NoteTitle, Password, - TagName, ValidationError, -}; +pub use ports::*; +pub use repositories::*; +pub use services::*; +pub use value_objects::*; diff --git a/notes-infra/Cargo.toml b/notes-infra/Cargo.toml index aaa07d2..629036a 100644 --- a/notes-infra/Cargo.toml +++ b/notes-infra/Cargo.toml @@ -4,18 +4,38 @@ version = "0.1.0" edition = "2024" [features] -default = ["sqlite", "smart-features", "broker-nats"] -sqlite = ["sqlx/sqlite", "k-core/sqlite", "tower-sessions-sqlx-store", "k-core/sessions-db"] -postgres = ["sqlx/postgres", "k-core/postgres", "tower-sessions-sqlx-store", "k-core/sessions-db"] +default = [ + "sqlite", + "smart-features", + "broker-nats", + "auth-jwt", + "auth-oidc", + "auth-axum-login", +] +sqlite = [ + "sqlx/sqlite", + "k-core/sqlite", + "tower-sessions-sqlx-store", + "k-core/sessions-db", +] +postgres = [ + "sqlx/postgres", + "k-core/postgres", + "tower-sessions-sqlx-store", + "k-core/sessions-db", +] smart-features = ["k-core/ai"] broker-nats = ["dep:futures-util", "k-core/broker-nats"] +auth-axum-login = ["dep:axum-login", "dep:password-auth"] +auth-oidc = ["dep:openidconnect", "dep:url"] +auth-jwt = ["dep:jsonwebtoken"] [dependencies] k-core = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-core", features = [ "logging", "db-sqlx", - "sessions-db" -], version = "*"} + "sessions-db", +], version = "*" } notes-domain = { path = "../notes-domain" } chrono = { version = "0.4.42", features = ["serde"] } @@ -31,4 +51,18 @@ futures-util = { version = "0.3", optional = true } futures-core = "0.3" async-trait = "0.1.89" anyhow = "1.0.100" -tower-sessions-sqlx-store = { version = "0.15.0", optional = true} +tower-sessions-sqlx-store = { version = "0.15.0", optional = true } +tower-sessions = "0.14" + +# Auth dependencies (optional) +axum-login = { version = "0.18", optional = true } +password-auth = { version = "1.0", optional = true } +openidconnect = { version = "4.0.1", optional = true } +url = { version = "2.5.8", optional = true } +jsonwebtoken = { version = "10.2.0", features = [ + "sha2", + "p256", + "hmac", + "rsa", + "rust_crypto", +], optional = true } diff --git a/notes-infra/src/auth/axum_login.rs b/notes-infra/src/auth/axum_login.rs new file mode 100644 index 0000000..75bd0fc --- /dev/null +++ b/notes-infra/src/auth/axum_login.rs @@ -0,0 +1,110 @@ +use std::sync::Arc; + +use axum_login::{AuthnBackend, UserId}; +use password_auth::verify_password; +use serde::{Deserialize, Serialize}; +use tower_sessions::SessionManagerLayer; +use uuid::Uuid; + +use notes_domain::{User, UserRepository}; + +use crate::session_store::InfraSessionStore; + +/// Wrapper around domain User to implement AuthUser +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthUser(pub User); + +impl axum_login::AuthUser for AuthUser { + type Id = Uuid; + + fn id(&self) -> Self::Id { + self.0.id + } + + fn session_auth_hash(&self) -> &[u8] { + // Use password hash to invalidate sessions if password changes + self.0 + .password_hash + .as_ref() + .map(|s| s.as_bytes()) + .unwrap_or(&[]) + } +} + +#[derive(Clone)] +pub struct AuthBackend { + pub user_repo: Arc, +} + +impl AuthBackend { + pub fn new(user_repo: Arc) -> Self { + Self { user_repo } + } +} + +#[derive(Clone, Debug, Deserialize)] +pub struct Credentials { + pub email: notes_domain::Email, + pub password: notes_domain::Password, +} + +#[derive(Debug, thiserror::Error)] +pub enum AuthError { + #[error(transparent)] + Anyhow(#[from] anyhow::Error), +} + +impl AuthnBackend for AuthBackend { + type User = AuthUser; + type Credentials = Credentials; + type Error = AuthError; + + async fn authenticate( + &self, + creds: Self::Credentials, + ) -> Result, Self::Error> { + let user = self + .user_repo + .find_by_email(creds.email.as_ref()) + .await + .map_err(|e| AuthError::Anyhow(anyhow::anyhow!(e)))?; + + if let Some(user) = user { + if let Some(hash) = &user.password_hash { + // Verify password + if verify_password(creds.password.as_ref(), hash).is_ok() { + return Ok(Some(AuthUser(user))); + } + } + } + + Ok(None) + } + + async fn get_user(&self, user_id: &UserId) -> Result, Self::Error> { + let user = self + .user_repo + .find_by_id(*user_id) + .await + .map_err(|e| AuthError::Anyhow(anyhow::anyhow!(e)))?; + + Ok(user.map(AuthUser)) + } +} + +pub type AuthSession = axum_login::AuthSession; +pub type AuthManagerLayer = axum_login::AuthManagerLayer; + +pub async fn setup_auth_layer( + session_layer: SessionManagerLayer, + user_repo: Arc, +) -> Result { + let backend = AuthBackend::new(user_repo); + + let auth_layer = axum_login::AuthManagerLayerBuilder::new(backend, session_layer).build(); + Ok(auth_layer) +} + +pub fn hash_password(password: &str) -> String { + password_auth::generate_hash(password) +} diff --git a/notes-infra/src/auth/jwt.rs b/notes-infra/src/auth/jwt.rs new file mode 100644 index 0000000..bf1c917 --- /dev/null +++ b/notes-infra/src/auth/jwt.rs @@ -0,0 +1,278 @@ +//! JWT Authentication Infrastructure +//! +//! Provides JWT token creation and validation using HS256 (secret-based). +//! For OIDC/JWKS validation, see the `oidc` module. + +use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode}; +use notes_domain::User; +use serde::{Deserialize, Serialize}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Minimum secret length for production (256 bits = 32 bytes) +const MIN_SECRET_LENGTH: usize = 32; + +/// JWT configuration +#[derive(Debug, Clone)] +pub struct JwtConfig { + /// Secret key for HS256 signing/verification + pub secret: String, + /// Expected issuer (for validation) + pub issuer: Option, + /// Expected audience (for validation) + pub audience: Option, + /// Token expiry in hours (default: 24) + pub expiry_hours: u64, +} + +impl JwtConfig { + /// Create a new JWT config with validation + /// + /// In production mode, this will reject weak secrets. + pub fn new( + secret: String, + issuer: Option, + audience: Option, + expiry_hours: Option, + is_production: bool, + ) -> Result { + // Validate secret strength in production + if is_production && secret.len() < MIN_SECRET_LENGTH { + return Err(JwtError::WeakSecret { + min_length: MIN_SECRET_LENGTH, + actual_length: secret.len(), + }); + } + + Ok(Self { + secret, + issuer, + audience, + expiry_hours: expiry_hours.unwrap_or(24), + }) + } + + /// Create config without validation (for testing) + pub fn new_unchecked(secret: String) -> Self { + Self { + secret, + issuer: None, + audience: None, + expiry_hours: 24, + } + } +} + +/// JWT claims structure +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct JwtClaims { + /// Subject - the user's unique identifier (user ID as string) + pub sub: String, + /// User's email address + pub email: String, + /// Expiry timestamp (seconds since UNIX epoch) + pub exp: usize, + /// Issued at timestamp (seconds since UNIX epoch) + pub iat: usize, + /// Issuer + #[serde(skip_serializing_if = "Option::is_none")] + pub iss: Option, + /// Audience + #[serde(skip_serializing_if = "Option::is_none")] + pub aud: Option, +} + +/// JWT-related errors +#[derive(Debug, thiserror::Error)] +pub enum JwtError { + #[error("JWT secret is too weak: minimum {min_length} bytes required, got {actual_length}")] + WeakSecret { + min_length: usize, + actual_length: usize, + }, + + #[error("Token creation failed: {0}")] + CreationFailed(#[from] jsonwebtoken::errors::Error), + + #[error("Token validation failed: {0}")] + ValidationFailed(String), + + #[error("Token expired")] + Expired, + + #[error("Invalid token format")] + InvalidFormat, + + #[error("Missing configuration")] + MissingConfig, +} + +/// JWT token validator and generator +#[derive(Clone)] +pub struct JwtValidator { + config: JwtConfig, + encoding_key: EncodingKey, + decoding_key: DecodingKey, + validation: Validation, +} + +impl JwtValidator { + /// Create a new JWT validator with the given configuration + pub fn new(config: JwtConfig) -> Self { + let encoding_key = EncodingKey::from_secret(config.secret.as_bytes()); + let decoding_key = DecodingKey::from_secret(config.secret.as_bytes()); + + let mut validation = Validation::new(Algorithm::HS256); + + // Configure issuer validation if set + if let Some(ref issuer) = config.issuer { + validation.set_issuer(&[issuer]); + } + + // Configure audience validation if set + if let Some(ref audience) = config.audience { + validation.set_audience(&[audience]); + } + + Self { + config, + encoding_key, + decoding_key, + validation, + } + } + + /// Create a JWT token for the given user + pub fn create_token(&self, user: &User) -> Result { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs() as usize; + + let expiry = now + (self.config.expiry_hours as usize * 3600); + + let claims = JwtClaims { + sub: user.id.to_string(), + email: user.email.as_ref().to_string(), + exp: expiry, + iat: now, + iss: self.config.issuer.clone(), + aud: self.config.audience.clone(), + }; + + let header = Header::new(Algorithm::HS256); + encode(&header, &claims, &self.encoding_key).map_err(JwtError::CreationFailed) + } + + /// Validate a JWT token and return the claims + pub fn validate_token(&self, token: &str) -> Result { + let token_data = decode::(token, &self.decoding_key, &self.validation).map_err( + |e| match e.kind() { + jsonwebtoken::errors::ErrorKind::ExpiredSignature => JwtError::Expired, + jsonwebtoken::errors::ErrorKind::InvalidToken => JwtError::InvalidFormat, + _ => JwtError::ValidationFailed(e.to_string()), + }, + )?; + + Ok(token_data.claims) + } + + /// Get the user ID (subject) from a token without full validation + /// Useful for logging/debugging, but should not be trusted for auth + pub fn decode_unverified(&self, token: &str) -> Result { + let mut validation = Validation::new(Algorithm::HS256); + validation.insecure_disable_signature_validation(); + validation.validate_exp = false; + + let token_data = decode::(token, &self.decoding_key, &validation) + .map_err(|_| JwtError::InvalidFormat)?; + + Ok(token_data.claims) + } +} + +impl std::fmt::Debug for JwtValidator { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("JwtValidator") + .field("issuer", &self.config.issuer) + .field("audience", &self.config.audience) + .field("expiry_hours", &self.config.expiry_hours) + .finish_non_exhaustive() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use notes_domain::Email; + + fn create_test_user() -> User { + let email = Email::try_from("test@example.com").unwrap(); + User::new("test-subject", email) + } + + #[test] + fn test_create_and_validate_token() { + let config = JwtConfig::new_unchecked("test-secret-key-that-is-long-enough".to_string()); + let validator = JwtValidator::new(config); + let user = create_test_user(); + + let token = validator.create_token(&user).expect("Should create token"); + let claims = validator + .validate_token(&token) + .expect("Should validate token"); + + assert_eq!(claims.sub, user.id.to_string()); + assert_eq!(claims.email, "test@example.com"); + } + + #[test] + fn test_weak_secret_rejected_in_production() { + let result = JwtConfig::new( + "short".to_string(), // Too short + None, + None, + None, + true, // Production mode + ); + + assert!(matches!(result, Err(JwtError::WeakSecret { .. }))); + } + + #[test] + fn test_weak_secret_allowed_in_development() { + let result = JwtConfig::new( + "short".to_string(), // Too short but OK in dev + None, + None, + None, + false, // Development mode + ); + + assert!(result.is_ok()); + } + + #[test] + fn test_invalid_token_rejected() { + let config = JwtConfig::new_unchecked("test-secret-key-that-is-long-enough".to_string()); + let validator = JwtValidator::new(config); + + let result = validator.validate_token("invalid.token.here"); + assert!(result.is_err()); + } + + #[test] + fn test_wrong_secret_rejected() { + let config1 = JwtConfig::new_unchecked("secret-one-that-is-long-enough".to_string()); + let config2 = JwtConfig::new_unchecked("secret-two-that-is-long-enough".to_string()); + + let validator1 = JwtValidator::new(config1); + let validator2 = JwtValidator::new(config2); + + let user = create_test_user(); + let token = validator1.create_token(&user).unwrap(); + + // Token from validator1 should fail on validator2 + let result = validator2.validate_token(&token); + assert!(result.is_err()); + } +} diff --git a/notes-infra/src/auth/mod.rs b/notes-infra/src/auth/mod.rs new file mode 100644 index 0000000..ac01f0b --- /dev/null +++ b/notes-infra/src/auth/mod.rs @@ -0,0 +1,6 @@ +#[cfg(feature = "auth-axum-login")] +mod axum_login; +#[cfg(feature = "auth-jwt")] +mod jwt; +#[cfg(feature = "auth-oidc")] +mod oidc; diff --git a/notes-infra/src/auth/oidc.rs b/notes-infra/src/auth/oidc.rs new file mode 100644 index 0000000..76f0929 --- /dev/null +++ b/notes-infra/src/auth/oidc.rs @@ -0,0 +1,202 @@ +use anyhow::anyhow; +use notes_domain::{ + AuthorizationCode, AuthorizationUrlData, ClientId, ClientSecret, CsrfToken, IssuerUrl, + OidcNonce, PkceVerifier, RedirectUrl, ResourceId, +}; +use openidconnect::{ + AccessTokenHash, Client, EmptyAdditionalClaims, EndpointMaybeSet, EndpointNotSet, EndpointSet, + OAuth2TokenResponse, PkceCodeChallenge, Scope, StandardErrorResponse, TokenResponse, + UserInfoClaims, + core::{ + CoreAuthDisplay, CoreAuthPrompt, CoreAuthenticationFlow, CoreClient, CoreErrorResponseType, + CoreGenderClaim, CoreJsonWebKey, CoreJweContentEncryptionAlgorithm, CoreProviderMetadata, + CoreRevocableToken, CoreRevocationErrorResponse, CoreTokenIntrospectionResponse, + CoreTokenResponse, + }, + reqwest, +}; + +pub type OidcClient = Client< + EmptyAdditionalClaims, + CoreAuthDisplay, + CoreGenderClaim, + CoreJweContentEncryptionAlgorithm, + CoreJsonWebKey, + CoreAuthPrompt, + StandardErrorResponse, + CoreTokenResponse, + CoreTokenIntrospectionResponse, + CoreRevocableToken, + CoreRevocationErrorResponse, + EndpointSet, // HasAuthUrl (Required and guaranteed by discovery) + EndpointNotSet, // HasDeviceAuthUrl + EndpointNotSet, // HasIntrospectionUrl + EndpointNotSet, // HasRevocationUrl + EndpointMaybeSet, // HasTokenUrl (Discovered, might be missing) + EndpointMaybeSet, // HasUserInfoUrl (Discovered, might be missing) +>; + +#[derive(Clone)] +pub struct OidcService { + client: OidcClient, + resource_id: Option, +} + +#[derive(Debug)] +pub struct OidcUser { + pub subject: String, + pub email: String, +} + +impl OidcService { + /// Create a new OIDC service with validated configuration newtypes + pub async fn new( + issuer: IssuerUrl, + client_id: ClientId, + client_secret: Option, + redirect_url: RedirectUrl, + resource_id: Option, + ) -> anyhow::Result { + tracing::debug!("🔵 OIDC Setup: Client ID = '{}'", client_id); + tracing::debug!("🔵 OIDC Setup: Redirect = '{}'", redirect_url); + tracing::debug!( + "🔵 OIDC Setup: Secret = {:?}", + if client_secret.is_some() { + "SET" + } else { + "NONE" + } + ); + + let http_client = reqwest::ClientBuilder::new() + .redirect(reqwest::redirect::Policy::none()) + .build()?; + + let provider_metadata = CoreProviderMetadata::discover_async( + openidconnect::IssuerUrl::new(issuer.as_ref().to_string())?, + &http_client, + ) + .await?; + + // Convert to openidconnect types + let oidc_client_id = openidconnect::ClientId::new(client_id.as_ref().to_string()); + let oidc_client_secret = client_secret + .as_ref() + .filter(|s| !s.is_empty()) + .map(|s| openidconnect::ClientSecret::new(s.as_ref().to_string())); + let oidc_redirect_url = openidconnect::RedirectUrl::new(redirect_url.as_ref().to_string())?; + + let client = CoreClient::from_provider_metadata( + provider_metadata, + oidc_client_id, + oidc_client_secret, + ) + .set_redirect_uri(oidc_redirect_url); + + Ok(Self { + client, + resource_id, + }) + } + + /// Get the authorization URL and associated state for OIDC login + /// + /// Returns structured data instead of a raw tuple for better type safety + pub fn get_authorization_url(&self) -> AuthorizationUrlData { + let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); + + let (auth_url, csrf_token, nonce) = self + .client + .authorize_url( + CoreAuthenticationFlow::AuthorizationCode, + openidconnect::CsrfToken::new_random, + openidconnect::Nonce::new_random, + ) + .add_scope(Scope::new("profile".to_string())) + .add_scope(Scope::new("email".to_string())) + .set_pkce_challenge(pkce_challenge) + .url(); + + AuthorizationUrlData { + url: auth_url.into(), + csrf_token: CsrfToken::new(csrf_token.secret().to_string()), + nonce: OidcNonce::new(nonce.secret().to_string()), + pkce_verifier: PkceVerifier::new(pkce_verifier.secret().to_string()), + } + } + + /// Resolve the OIDC callback with type-safe parameters + pub async fn resolve_callback( + &self, + code: AuthorizationCode, + nonce: OidcNonce, + pkce_verifier: PkceVerifier, + ) -> anyhow::Result { + let http_client = reqwest::ClientBuilder::new() + .redirect(reqwest::redirect::Policy::none()) + .build()?; + + let oidc_pkce_verifier = + openidconnect::PkceCodeVerifier::new(pkce_verifier.as_ref().to_string()); + let oidc_nonce = openidconnect::Nonce::new(nonce.as_ref().to_string()); + + let token_response = self + .client + .exchange_code(openidconnect::AuthorizationCode::new( + code.as_ref().to_string(), + ))? + .set_pkce_verifier(oidc_pkce_verifier) + .request_async(&http_client) + .await?; + + let id_token = token_response + .id_token() + .ok_or_else(|| anyhow!("Server did not return an ID token"))?; + + let mut id_token_verifier = self.client.id_token_verifier().clone(); + + if let Some(resource_id) = &self.resource_id { + let trusted_resource_id = resource_id.as_ref().to_string(); + id_token_verifier = id_token_verifier + .set_other_audience_verifier_fn(move |aud| aud.as_str() == trusted_resource_id); + } + + let claims = id_token.claims(&id_token_verifier, &oidc_nonce)?; + + if let Some(expected_access_token_hash) = claims.access_token_hash() { + let actual_access_token_hash = AccessTokenHash::from_token( + token_response.access_token(), + id_token.signing_alg()?, + id_token.signing_key(&id_token_verifier)?, + )?; + + if actual_access_token_hash != *expected_access_token_hash { + return Err(anyhow!("Invalid access token")); + } + } + + let email = if let Some(email) = claims.email() { + Some(email.as_str().to_string()) + } else { + // Fallback: Call UserInfo Endpoint using the Access Token + tracing::debug!("🔵 Email missing in ID Token, fetching UserInfo..."); + + let user_info: UserInfoClaims = self + .client + .user_info(token_response.access_token().clone(), None)? + .request_async(&http_client) + .await?; + + user_info.email().map(|e| e.as_str().to_string()) + }; + + // If email is still missing, we must error out because your app requires valid emails + let email = + email.ok_or_else(|| anyhow!("User has no verified email address in ZITADEL"))?; + + Ok(OidcUser { + subject: claims.subject().to_string(), + email, + }) + } +} diff --git a/notes-infra/src/lib.rs b/notes-infra/src/lib.rs index 2a0ad21..9b4659e 100644 --- a/notes-infra/src/lib.rs +++ b/notes-infra/src/lib.rs @@ -13,6 +13,7 @@ //! //! - [`db::run_migrations`] - Run database migrations +pub mod auth; #[cfg(feature = "broker-nats")] pub mod broker; pub mod db;