auth in infra

This commit is contained in:
2026-01-06 19:39:05 +01:00
parent 6a3259d347
commit 82a6c08790
8 changed files with 1051 additions and 23 deletions

418
Cargo.lock generated
View File

@@ -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",

View File

@@ -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::*;

View File

@@ -4,17 +4,37 @@ 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"
"sessions-db",
], version = "*" }
notes-domain = { path = "../notes-domain" }
@@ -32,3 +52,17 @@ 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 = "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 }

View File

@@ -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<dyn UserRepository>,
}
impl AuthBackend {
pub fn new(user_repo: Arc<dyn UserRepository>) -> 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<Option<Self::User>, 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<Self>) -> Result<Option<Self::User>, 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<AuthBackend>;
pub type AuthManagerLayer = axum_login::AuthManagerLayer<AuthBackend, InfraSessionStore>;
pub async fn setup_auth_layer(
session_layer: SessionManagerLayer<InfraSessionStore>,
user_repo: Arc<dyn UserRepository>,
) -> Result<AuthManagerLayer, AuthError> {
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)
}

278
notes-infra/src/auth/jwt.rs Normal file
View File

@@ -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<String>,
/// Expected audience (for validation)
pub audience: Option<String>,
/// 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<String>,
audience: Option<String>,
expiry_hours: Option<u64>,
is_production: bool,
) -> Result<Self, JwtError> {
// 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<String>,
/// Audience
#[serde(skip_serializing_if = "Option::is_none")]
pub aud: Option<String>,
}
/// 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<String, JwtError> {
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<JwtClaims, JwtError> {
let token_data = decode::<JwtClaims>(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<JwtClaims, JwtError> {
let mut validation = Validation::new(Algorithm::HS256);
validation.insecure_disable_signature_validation();
validation.validate_exp = false;
let token_data = decode::<JwtClaims>(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());
}
}

View File

@@ -0,0 +1,6 @@
#[cfg(feature = "auth-axum-login")]
mod axum_login;
#[cfg(feature = "auth-jwt")]
mod jwt;
#[cfg(feature = "auth-oidc")]
mod oidc;

View File

@@ -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<CoreErrorResponseType>,
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<ResourceId>,
}
#[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<ClientSecret>,
redirect_url: RedirectUrl,
resource_id: Option<ResourceId>,
) -> anyhow::Result<Self> {
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<OidcUser> {
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<EmptyAdditionalClaims, CoreGenderClaim> = 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,
})
}
}

View File

@@ -13,6 +13,7 @@
//!
//! - [`db::run_migrations`] - Run database migrations
pub mod auth;
#[cfg(feature = "broker-nats")]
pub mod broker;
pub mod db;