From 9ca4eeddb4493122ce4505a3735cdf87281142e1 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Thu, 5 Mar 2026 01:28:27 +0100 Subject: [PATCH] feat: enhance application state management with cookie support - Added cookie key to AppState for managing session cookies. - Updated AppState initialization to derive cookie key from configuration. - Removed session-based authentication option from cargo-generate prompts. - Refactored JWT authentication logic to improve clarity and error handling. - Updated password validation to align with NIST recommendations (minimum length increased). - Removed unused session store implementation and related code. - Improved error handling in user repository for unique constraint violations. - Refactored OIDC service to include state management for authentication flow. - Cleaned up dependencies in Cargo.toml and Cargo.toml.template for clarity and efficiency. --- Cargo.lock | 435 ++++++++++----------------------- api/Cargo.toml | 26 +- api/Cargo.toml.template | 30 +-- api/src/auth.rs | 27 --- api/src/config.rs | 96 +------- api/src/dto.rs | 14 +- api/src/error.rs | 12 +- api/src/extractors.rs | 84 ++----- api/src/main.rs | 107 ++------ api/src/routes/auth.rs | 456 +++++++++-------------------------- api/src/state.rs | 38 ++- cargo-generate.toml | 5 - domain/Cargo.toml | 4 - domain/src/entities.rs | 4 - domain/src/errors.rs | 22 +- domain/src/repositories.rs | 2 +- domain/src/value_objects.rs | 97 +------- infra/Cargo.toml | 30 +-- infra/Cargo.toml.template | 33 +-- infra/src/auth/mod.rs | 122 +--------- infra/src/auth/oidc.rs | 52 ++-- infra/src/factory.rs | 17 -- infra/src/lib.rs | 3 - infra/src/session_store.rs | 2 - infra/src/user_repository.rs | 62 +++-- 25 files changed, 440 insertions(+), 1340 deletions(-) delete mode 100644 api/src/auth.rs delete mode 100644 infra/src/session_store.rs diff --git a/Cargo.lock b/Cargo.lock index 70d75f8..5c86870 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,41 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -37,10 +72,9 @@ name = "api" version = "0.1.0" dependencies = [ "anyhow", - "async-trait", "axum", + "axum-extra", "chrono", - "config", "domain", "dotenvy", "infra", @@ -52,10 +86,7 @@ dependencies = [ "tokio", "tower", "tower-http", - "tower-sessions", - "tower-sessions-sqlx-store", "tracing", - "tracing-subscriber", "uuid", ] @@ -71,12 +102,6 @@ dependencies = [ "password-hash", ] -[[package]] -name = "arraydeque" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" - [[package]] name = "async-nats" version = "0.45.0" @@ -200,22 +225,26 @@ dependencies = [ ] [[package]] -name = "axum-login" -version = "0.18.0" +name = "axum-extra" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "964ea6eb764a227baa8c3368e45c94d23b6863cc7b880c6c9e341c143c5a5ff7" +checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96" dependencies = [ "axum", - "form_urlencoded", - "serde", - "subtle", - "thiserror 2.0.17", - "tower-cookies", + "axum-core", + "bytes", + "cookie", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "serde_core", "tower-layer", "tower-service", - "tower-sessions", "tracing", - "urlencoding", ] [[package]] @@ -337,6 +366,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -346,68 +385,25 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "config" -version = "0.15.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b30fa8254caad766fc03cb0ccae691e14bf3bd72bfff27f72802ce729551b3d6" -dependencies = [ - "async-trait", - "convert_case", - "json5", - "pathdiff", - "ron", - "rust-ini", - "serde-untagged", - "serde_core", - "serde_json", - "toml", - "winnow", - "yaml-rust2", -] - [[package]] name = "const-oid" version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" -[[package]] -name = "const-random" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" -dependencies = [ - "const-random-macro", -] - -[[package]] -name = "const-random-macro" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" -dependencies = [ - "getrandom 0.2.16", - "once_cell", - "tiny-keccak", -] - -[[package]] -name = "convert_case" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" -dependencies = [ - "unicode-segmentation", -] - [[package]] name = "cookie" version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" dependencies = [ + "aes-gcm", + "base64 0.22.1", + "hkdf", "percent-encoding", + "rand 0.8.5", + "sha2", + "subtle", "time", "version_check", ] @@ -467,12 +463,6 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - [[package]] name = "crypto-bigint" version = "0.5.5" @@ -492,9 +482,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -607,29 +607,16 @@ dependencies = [ "syn", ] -[[package]] -name = "dlv-list" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" -dependencies = [ - "const-random", -] - [[package]] name = "domain" version = "0.1.0" dependencies = [ - "anyhow", "async-trait", "chrono", "email_address", - "futures-core", "serde", - "serde_json", "thiserror 2.0.17", "tokio", - "tracing", "url", "uuid", ] @@ -724,32 +711,12 @@ dependencies = [ "serde", ] -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" -[[package]] -name = "erased-serde" -version = "0.4.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e8918065695684b2b0702da20382d5ae6065cf3327bc2d6436bd49a71ce9f3" -dependencies = [ - "serde", - "serde_core", - "typeid", -] - [[package]] name = "errno" version = "0.3.14" @@ -972,6 +939,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "group" version = "0.13.0" @@ -989,12 +966,6 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" - [[package]] name = "hashbrown" version = "0.15.5" @@ -1330,7 +1301,7 @@ dependencies = [ "anyhow", "async-nats", "async-trait", - "axum-login", + "axum-extra", "chrono", "domain", "futures-core", @@ -1344,13 +1315,20 @@ dependencies = [ "sqlx", "thiserror 2.0.17", "tokio", - "tower-sessions", - "tower-sessions-sqlx-store", "tracing", "url", "uuid", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -1392,17 +1370,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "json5" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" -dependencies = [ - "pest", - "pest_derive", - "serde", -] - [[package]] name = "jsonwebtoken" version = "10.2.0" @@ -1692,6 +1659,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openidconnect" version = "4.0.1" @@ -1738,16 +1711,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "ordered-multimap" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" -dependencies = [ - "dlv-list", - "hashbrown 0.14.5", -] - [[package]] name = "p256" version = "0.13.2" @@ -1824,12 +1787,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "pathdiff" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" - [[package]] name = "pem" version = "3.0.6" @@ -1855,49 +1812,6 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "pest" -version = "2.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbcfd20a6d4eeba40179f05735784ad32bdaef05ce8e8af05f180d45bb3e7e22" -dependencies = [ - "memchr", - "ucd-trie", -] - -[[package]] -name = "pest_derive" -version = "2.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51f72981ade67b1ca6adc26ec221be9f463f2b5839c7508998daa17c23d94d7f" -dependencies = [ - "pest", - "pest_generator", -] - -[[package]] -name = "pest_generator" -version = "2.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dee9efd8cdb50d719a80088b76f81aec7c41ed6d522ee750178f83883d271625" -dependencies = [ - "pest", - "pest_meta", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "pest_meta" -version = "2.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf1d70880e76bdc13ba52eafa6239ce793d85c8e43896507e43dd8984ff05b82" -dependencies = [ - "pest", - "sha2", -] - [[package]] name = "pin-project" version = "1.1.10" @@ -1957,6 +1871,18 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.13.0" @@ -2282,20 +2208,6 @@ dependencies = [ "serde", ] -[[package]] -name = "ron" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd490c5b18261893f14449cbd28cb9c0b637aebf161cd77900bfdedaff21ec32" -dependencies = [ - "bitflags", - "once_cell", - "serde", - "serde_derive", - "typeid", - "unicode-ident", -] - [[package]] name = "rsa" version = "0.9.9" @@ -2316,16 +2228,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rust-ini" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" -dependencies = [ - "cfg-if", - "ordered-multimap", -] - [[package]] name = "rustc-hash" version = "2.1.1" @@ -2512,18 +2414,6 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "serde-untagged" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" -dependencies = [ - "erased-serde", - "serde", - "serde_core", - "typeid", -] - [[package]] name = "serde-value" version = "0.7.0" @@ -2607,15 +2497,6 @@ dependencies = [ "syn", ] -[[package]] -name = "serde_spanned" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" -dependencies = [ - "serde_core", -] - [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -3124,15 +3005,6 @@ dependencies = [ "time-core", ] -[[package]] -name = "tiny-keccak" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" -dependencies = [ - "crunchy", -] - [[package]] name = "tinystr" version = "0.8.2" @@ -3241,37 +3113,6 @@ dependencies = [ "webpki-roots 0.26.11", ] -[[package]] -name = "toml" -version = "0.9.10+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0825052159284a1a8b4d6c0c86cbc801f2da5afd2b225fa548c72f2e74002f48" -dependencies = [ - "serde_core", - "serde_spanned", - "toml_datetime", - "toml_parser", - "winnow", -] - -[[package]] -name = "toml_datetime" -version = "0.7.5+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_parser" -version = "1.0.6+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" -dependencies = [ - "winnow", -] - [[package]] name = "tower" version = "0.5.2" @@ -3478,24 +3319,12 @@ dependencies = [ "tokio", ] -[[package]] -name = "typeid" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" - [[package]] name = "typenum" version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" -[[package]] -name = "ucd-trie" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" - [[package]] name = "unicode-bidi" version = "0.3.18" @@ -3524,10 +3353,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" [[package]] -name = "unicode-segmentation" -version = "1.12.0" +name = "universal-hash" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] [[package]] name = "untrusted" @@ -3548,12 +3381,6 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "urlencoding" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -4007,15 +3834,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" -[[package]] -name = "winnow" -version = "0.7.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" -dependencies = [ - "memchr", -] - [[package]] name = "wit-bindgen" version = "0.46.0" @@ -4028,17 +3846,6 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" -[[package]] -name = "yaml-rust2" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2462ea039c445496d8793d052e13787f2b90e750b833afee748e601c17621ed9" -dependencies = [ - "arraydeque", - "encoding_rs", - "hashlink", -] - [[package]] name = "yoke" version = "0.8.1" diff --git a/api/Cargo.toml b/api/Cargo.toml index 402bdc3..4e12b83 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -5,13 +5,11 @@ edition = "2024" default-run = "api" [features] -default = ["sqlite"] -sqlite = ["infra/sqlite", "tower-sessions-sqlx-store/sqlite"] -postgres = ["infra/postgres", "tower-sessions-sqlx-store/postgres"] -auth-axum-login = ["infra/auth-axum-login"] +default = ["sqlite", "auth-jwt"] +sqlite = ["infra/sqlite"] +postgres = ["infra/postgres"] auth-oidc = ["infra/auth-oidc"] auth-jwt = ["infra/auth-jwt"] -auth-full = ["auth-axum-login", "auth-oidc", "auth-jwt"] [dependencies] k-core = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-core", features = [ @@ -19,24 +17,16 @@ k-core = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-core", features "db-sqlx", "sqlite", "http", - "auth", - "sessions-db", ] } domain = { path = "../domain" } infra = { path = "../infra", default-features = false, features = ["sqlite"] } -#Web framework +# Web framework axum = { version = "0.8.8", features = ["macros"] } +axum-extra = { version = "0.10", features = ["cookie-private", "cookie-key-expansion"] } tower = "0.5.2" tower-http = { version = "0.6.2", features = ["cors", "trace"] } -# Authentication -# Moved to infra -tower-sessions-sqlx-store = { version = "0.15", features = ["sqlite"] } -# password-auth removed -time = "0.3" -async-trait = "0.1.89" - # Async runtime tokio = { version = "1.48.0", features = ["full"] } @@ -44,8 +34,6 @@ tokio = { version = "1.48.0", features = ["full"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0" -# Validation via domain newtypes (Email, Password) - # Error handling thiserror = "2.0.17" anyhow = "1.0" @@ -56,8 +44,6 @@ uuid = { version = "1.19.0", features = ["v4", "serde"] } # Logging tracing = "0.1" -tracing-subscriber = { version = "0.3.22", features = ["env-filter"] } dotenvy = "0.15.7" -config = "0.15.19" -tower-sessions = "0.14.0" +time = "0.3" diff --git a/api/Cargo.toml.template b/api/Cargo.toml.template index 0db268c..f0820de 100644 --- a/api/Cargo.toml.template +++ b/api/Cargo.toml.template @@ -1,17 +1,15 @@ [package] -name = "api" +name = "{{project_name}}" version = "0.1.0" edition = "2024" -default-run = "api" +default-run = "{{project_name}}" [features] -default = ["{{database}}"{% if auth_session %}, "auth-axum-login"{% endif %}{% if auth_oidc %}, "auth-oidc"{% endif %}{% if auth_jwt %}, "auth-jwt"{% endif %}] -sqlite = ["infra/sqlite", "tower-sessions-sqlx-store/sqlite"] -postgres = ["infra/postgres", "tower-sessions-sqlx-store/postgres"] -auth-axum-login = ["infra/auth-axum-login"] +default = ["{{database}}"{% if auth_oidc %}, "auth-oidc"{% endif %}{% if auth_jwt %}, "auth-jwt"{% endif %}] +sqlite = ["infra/sqlite"] +postgres = ["infra/postgres"] auth-oidc = ["infra/auth-oidc"] auth-jwt = ["infra/auth-jwt"] -auth-full = ["auth-axum-login", "auth-oidc", "auth-jwt"] [dependencies] k-core = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-core", features = [ @@ -19,24 +17,16 @@ k-core = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-core", features "db-sqlx", "{{database}}", "http", - "auth", - "sessions-db", ] } domain = { path = "../domain" } infra = { path = "../infra", default-features = false, features = ["{{database}}"] } -#Web framework +# Web framework axum = { version = "0.8.8", features = ["macros"] } +axum-extra = { version = "0.10", features = ["cookie-private", "cookie-key-expansion"] } tower = "0.5.2" tower-http = { version = "0.6.2", features = ["cors", "trace"] } -# Authentication -# Moved to infra -tower-sessions-sqlx-store = { version = "0.15", features = ["{{database}}"] } -# password-auth removed -time = "0.3" -async-trait = "0.1.89" - # Async runtime tokio = { version = "1.48.0", features = ["full"] } @@ -44,8 +34,6 @@ tokio = { version = "1.48.0", features = ["full"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0" -# Validation via domain newtypes (Email, Password) - # Error handling thiserror = "2.0.17" anyhow = "1.0" @@ -56,8 +44,6 @@ uuid = { version = "1.19.0", features = ["v4", "serde"] } # Logging tracing = "0.1" -tracing-subscriber = { version = "0.3.22", features = ["env-filter"] } dotenvy = "0.15.7" -config = "0.15.19" -tower-sessions = "0.14.0" +time = "0.3" diff --git a/api/src/auth.rs b/api/src/auth.rs deleted file mode 100644 index b0ba3b6..0000000 --- a/api/src/auth.rs +++ /dev/null @@ -1,27 +0,0 @@ -//! Authentication logic -//! -//! Proxies to infra implementation if enabled. - -#[cfg(feature = "auth-axum-login")] -use std::sync::Arc; - -#[cfg(feature = "auth-axum-login")] -use domain::UserRepository; -#[cfg(feature = "auth-axum-login")] -use infra::session_store::{InfraSessionStore, SessionManagerLayer}; - -#[cfg(feature = "auth-axum-login")] -use crate::error::ApiError; - -#[cfg(feature = "auth-axum-login")] -pub use infra::auth::backend::{AuthManagerLayer, AuthSession, AuthUser, Credentials}; - -#[cfg(feature = "auth-axum-login")] -pub async fn setup_auth_layer( - session_layer: SessionManagerLayer, - user_repo: Arc, -) -> Result { - infra::auth::backend::setup_auth_layer(session_layer, user_repo) - .await - .map_err(|e| ApiError::Internal(e.to_string())) -} diff --git a/api/src/config.rs b/api/src/config.rs index 524ab9e..870a759 100644 --- a/api/src/config.rs +++ b/api/src/config.rs @@ -4,52 +4,16 @@ use std::env; -use serde::Deserialize; - -/// Authentication mode - determines how the API authenticates requests -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum AuthMode { - /// Session-based authentication using cookies (default for backward compatibility) - #[default] - Session, - /// JWT-based authentication using Bearer tokens - Jwt, - /// Support both session and JWT authentication (try JWT first, then session) - Both, -} - -impl AuthMode { - /// Parse auth mode from string - pub fn from_str(s: &str) -> Self { - match s.to_lowercase().as_str() { - "jwt" => AuthMode::Jwt, - "both" => AuthMode::Both, - _ => AuthMode::Session, - } - } -} - -//todo: replace with newtypes -#[derive(Debug, Clone, Deserialize)] +/// Application configuration loaded from environment variables +#[derive(Debug, Clone)] pub struct Config { pub database_url: String, - pub session_secret: String, + pub cookie_secret: String, pub cors_allowed_origins: Vec, - - #[serde(default = "default_port")] pub port: u16, - - #[serde(default = "default_host")] pub host: String, - - #[serde(default = "default_secure_cookie")] pub secure_cookie: bool, - - #[serde(default = "default_db_max_connections")] pub db_max_connections: u32, - - #[serde(default = "default_db_min_connections")] pub db_min_connections: u32, // OIDC configuration @@ -59,57 +23,18 @@ pub struct Config { pub oidc_redirect_url: Option, pub oidc_resource_id: Option, - // Auth mode configuration - #[serde(default)] - pub auth_mode: AuthMode, - // JWT configuration pub jwt_secret: Option, pub jwt_issuer: Option, pub jwt_audience: Option, - #[serde(default = "default_jwt_expiry_hours")] pub jwt_expiry_hours: u64, /// Whether the application is running in production mode - #[serde(default)] pub is_production: bool, } -fn default_secure_cookie() -> bool { - false -} - -fn default_db_max_connections() -> u32 { - 5 -} - -fn default_db_min_connections() -> u32 { - 1 -} - -fn default_port() -> u16 { - 3000 -} - -fn default_host() -> String { - "127.0.0.1".to_string() -} - -fn default_jwt_expiry_hours() -> u64 { - 24 -} - impl Config { - pub fn new() -> Result { - config::Config::builder() - .add_source(config::Environment::default()) - //.add_source(config::File::with_name(".env").required(false)) // Optional .env file - .build()? - .try_deserialize() - } - pub fn from_env() -> Self { - // Load .env file if it exists, ignore errors if it doesn't let _ = dotenvy::dotenv(); let host = env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); @@ -121,8 +46,10 @@ impl Config { let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite:data.db?mode=rwc".to_string()); - let session_secret = env::var("SESSION_SECRET").unwrap_or_else(|_| { - "k-notes-super-secret-key-must-be-at-least-64-bytes-long!!!!".to_string() + // Cookie secret for PrivateCookieJar (OIDC state encryption). + // Must be at least 64 bytes in production. + let cookie_secret = env::var("COOKIE_SECRET").unwrap_or_else(|_| { + "k-template-cookie-secret-key-must-be-at-least-64-bytes-long!!".to_string() }); let cors_origins_str = env::var("CORS_ALLOWED_ORIGINS") @@ -155,12 +82,6 @@ impl Config { let oidc_redirect_url = env::var("OIDC_REDIRECT_URL").ok(); let oidc_resource_id = env::var("OIDC_RESOURCE_ID").ok(); - // Auth mode configuration - let auth_mode = env::var("AUTH_MODE") - .map(|s| AuthMode::from_str(&s)) - .unwrap_or_default(); - - // JWT configuration let jwt_secret = env::var("JWT_SECRET").ok(); let jwt_issuer = env::var("JWT_ISSUER").ok(); let jwt_audience = env::var("JWT_AUDIENCE").ok(); @@ -178,7 +99,7 @@ impl Config { host, port, database_url, - session_secret, + cookie_secret, cors_allowed_origins, secure_cookie, db_max_connections, @@ -188,7 +109,6 @@ impl Config { oidc_client_secret, oidc_redirect_url, oidc_resource_id, - auth_mode, jwt_secret, jwt_issuer, jwt_audience, diff --git a/api/src/dto.rs b/api/src/dto.rs index 3b3355a..4723301 100644 --- a/api/src/dto.rs +++ b/api/src/dto.rs @@ -10,21 +10,19 @@ use uuid::Uuid; /// Login request with validated email and password newtypes #[derive(Debug, Deserialize)] -#[allow(dead_code)] pub struct LoginRequest { /// Email is validated on deserialization pub email: Email, - /// Password is validated on deserialization (min 6 chars) + /// Password is validated on deserialization (min 8 chars) pub password: Password, } /// Register request with validated email and password newtypes #[derive(Debug, Deserialize)] -#[allow(dead_code)] pub struct RegisterRequest { /// Email is validated on deserialization pub email: Email, - /// Password is validated on deserialization (min 6 chars) + /// Password is validated on deserialization (min 8 chars) pub password: Password, } @@ -36,6 +34,14 @@ pub struct UserResponse { pub created_at: DateTime, } +/// JWT token response +#[derive(Debug, Serialize)] +pub struct TokenResponse { + pub access_token: String, + pub token_type: String, + pub expires_in: u64, +} + /// System configuration response #[derive(Debug, Serialize)] pub struct ConfigResponse { diff --git a/api/src/error.rs b/api/src/error.rs index 7e39348..04ef4b0 100644 --- a/api/src/error.rs +++ b/api/src/error.rs @@ -14,7 +14,6 @@ use domain::DomainError; /// API-level errors #[derive(Debug, Error)] -#[allow(dead_code)] // Some variants are reserved for future use pub enum ApiError { #[error("{0}")] Domain(#[from] DomainError), @@ -51,11 +50,17 @@ impl IntoResponse for ApiError { DomainError::ValidationError(_) => StatusCode::BAD_REQUEST, - DomainError::Unauthorized(_) => StatusCode::FORBIDDEN, + // Unauthenticated = not logged in → 401 + DomainError::Unauthenticated(_) => StatusCode::UNAUTHORIZED, + + // Forbidden = not allowed to perform action → 403 + DomainError::Forbidden(_) => StatusCode::FORBIDDEN, DomainError::RepositoryError(_) | DomainError::InfrastructureError(_) => { StatusCode::INTERNAL_SERVER_ERROR } + + _ => StatusCode::INTERNAL_SERVER_ERROR, }; ( @@ -76,7 +81,6 @@ impl IntoResponse for ApiError { ), ApiError::Internal(msg) => { - // Log internal errors but don't expose details tracing::error!("Internal error: {}", msg); ( StatusCode::INTERNAL_SERVER_ERROR, @@ -108,7 +112,6 @@ impl IntoResponse for ApiError { } } -#[allow(dead_code)] // Helper constructors for future use impl ApiError { pub fn validation(msg: impl Into) -> Self { Self::Validation(msg.into()) @@ -120,5 +123,4 @@ impl ApiError { } /// Result type alias for API handlers -#[allow(dead_code)] pub type ApiResult = Result; diff --git a/api/src/extractors.rs b/api/src/extractors.rs index d1cae72..0b25acf 100644 --- a/api/src/extractors.rs +++ b/api/src/extractors.rs @@ -1,20 +1,16 @@ //! Auth extractors for API handlers //! -//! Provides the `CurrentUser` extractor that works with both session and JWT auth. +//! Provides the `CurrentUser` extractor that validates JWT Bearer tokens. use axum::{extract::FromRequestParts, http::request::Parts}; use domain::User; -use crate::config::AuthMode; use crate::error::ApiError; use crate::state::AppState; /// Extracted current user from the request. /// -/// This extractor supports multiple authentication methods based on the configured `AuthMode`: -/// - `Session`: Uses axum-login session cookies -/// - `Jwt`: Uses Bearer token in Authorization header -/// - `Both`: Tries JWT first, then falls back to session +/// Validates a JWT Bearer token from the `Authorization` header. pub struct CurrentUser(pub User); impl FromRequestParts for CurrentUser { @@ -24,71 +20,47 @@ impl FromRequestParts for CurrentUser { parts: &mut Parts, state: &AppState, ) -> Result { - let auth_mode = state.config.auth_mode; - - // Try JWT first if enabled #[cfg(feature = "auth-jwt")] - if matches!(auth_mode, AuthMode::Jwt | AuthMode::Both) { - match try_jwt_auth(parts, state).await { - Ok(Some(user)) => return Ok(CurrentUser(user)), - Ok(None) => { - // No JWT token present, continue to session auth if Both mode - if auth_mode == AuthMode::Jwt { - return Err(ApiError::Unauthorized( - "Missing or invalid Authorization header".to_string(), - )); - } - } - Err(e) => { - // JWT was present but invalid - tracing::debug!("JWT auth failed: {}", e); - if auth_mode == AuthMode::Jwt { - return Err(e); - } - // In Both mode, continue to try session - } - } + { + return match try_jwt_auth(parts, state).await { + Ok(user) => Ok(CurrentUser(user)), + Err(e) => Err(e), + }; } - // Try session auth if enabled - #[cfg(feature = "auth-axum-login")] - if matches!(auth_mode, AuthMode::Session | AuthMode::Both) { - if let Some(user) = try_session_auth(parts).await? { - return Ok(CurrentUser(user)); - } + #[cfg(not(feature = "auth-jwt"))] + { + let _ = (parts, state); + Err(ApiError::Unauthorized( + "No authentication backend configured".to_string(), + )) } - - Err(ApiError::Unauthorized("Not authenticated".to_string())) } } -/// Try to authenticate using JWT Bearer token +/// Authenticate using JWT Bearer token #[cfg(feature = "auth-jwt")] -async fn try_jwt_auth(parts: &mut Parts, state: &AppState) -> Result, ApiError> { +async fn try_jwt_auth(parts: &mut Parts, state: &AppState) -> Result { use axum::http::header::AUTHORIZATION; - // Get Authorization header - let auth_header = match parts.headers.get(AUTHORIZATION) { - Some(header) => header, - None => return Ok(None), // No header = no JWT auth attempted - }; + let auth_header = parts + .headers + .get(AUTHORIZATION) + .ok_or_else(|| ApiError::Unauthorized("Missing Authorization header".to_string()))?; let auth_str = auth_header .to_str() .map_err(|_| ApiError::Unauthorized("Invalid Authorization header encoding".to_string()))?; - // Extract Bearer token let token = auth_str.strip_prefix("Bearer ").ok_or_else(|| { ApiError::Unauthorized("Authorization header must use Bearer scheme".to_string()) })?; - // Get JWT validator let validator = state .jwt_validator .as_ref() .ok_or_else(|| ApiError::Internal("JWT validator not configured".to_string()))?; - // Validate token let claims = validator.validate_token(token).map_err(|e| { tracing::debug!("JWT validation failed: {:?}", e); match e { @@ -102,7 +74,6 @@ async fn try_jwt_auth(parts: &mut Parts, state: &AppState) -> Result Result Result, ApiError> { - use infra::auth::backend::AuthSession; - - // Check if AuthSession extension is present (added by auth middleware) - if let Some(auth_session) = parts.extensions.get::() { - if let Some(auth_user) = &auth_session.user { - return Ok(Some(auth_user.0.clone())); - } - } - - Ok(None) + Ok(user) } diff --git a/api/src/main.rs b/api/src/main.rs index b498ed2..4a06b50 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -1,25 +1,19 @@ //! API Server Entry Point //! -//! Configures and starts the HTTP server with authentication based on AUTH_MODE. +//! Configures and starts the HTTP server with JWT-based authentication. use std::net::SocketAddr; use std::time::Duration as StdDuration; use axum::Router; use domain::UserService; -use infra::factory::build_session_store; use infra::factory::build_user_repository; use infra::run_migrations; -use infra::session_store::{Expiry, SessionManagerLayer}; -use k_core::http::server::ServerConfig; -use k_core::http::server::apply_standard_middleware; +use k_core::http::server::{ServerConfig, apply_standard_middleware}; use k_core::logging; -use time::Duration; use tokio::net::TcpListener; -use tower_sessions::cookie::SameSite; use tracing::info; -mod auth; mod config; mod dto; mod error; @@ -27,7 +21,7 @@ mod extractors; mod routes; mod state; -use crate::config::{AuthMode, Config}; +use crate::config::Config; use crate::state::AppState; #[tokio::main] @@ -37,7 +31,6 @@ async fn main() -> anyhow::Result<()> { let config = Config::from_env(); info!("Starting server on {}:{}", config.host, config.port); - info!("Auth mode: {:?}", config.auth_mode); // Setup database tracing::info!("Connecting to database: {}", config.database_url); @@ -49,104 +42,40 @@ async fn main() -> anyhow::Result<()> { }; let db_pool = k_core::db::connect(&db_config).await?; - run_migrations(&db_pool).await?; let user_repo = build_user_repository(&db_pool).await?; - let user_service = UserService::new(user_repo.clone()); + let user_service = UserService::new(user_repo); let state = AppState::new(user_service, config.clone()).await?; - // Build session store (needed for OIDC flow even in JWT mode) - let session_store = build_session_store(&db_pool) - .await - .map_err(|e| anyhow::anyhow!(e))?; - session_store - .migrate() - .await - .map_err(|e| anyhow::anyhow!(e))?; - - let session_layer = SessionManagerLayer::new(session_store) - .with_secure(config.secure_cookie) - .with_same_site(SameSite::Lax) - .with_expiry(Expiry::OnInactivity(Duration::days(7))); - let server_config = ServerConfig { cors_origins: config.cors_allowed_origins.clone(), - session_secret: Some(config.session_secret.clone()), + // session_secret is unused (sessions removed); kept for k-core API compat + session_secret: None, }; - // Build the app with appropriate auth layers based on config - let app = build_app(state, session_layer, user_repo, &config).await?; + let app = Router::new() + .nest("/api/v1", routes::api_v1_router()) + .with_state(state); + let app = apply_standard_middleware(app, &server_config); let addr: SocketAddr = format!("{}:{}", config.host, config.port).parse()?; let listener = TcpListener::bind(addr).await?; tracing::info!("🚀 API server running at http://{}", addr); - log_auth_info(&config); + tracing::info!("🔒 Authentication mode: JWT (Bearer token)"); + + #[cfg(feature = "auth-jwt")] + tracing::info!(" ✓ JWT auth enabled"); + + #[cfg(feature = "auth-oidc")] + tracing::info!(" ✓ OIDC integration enabled (stateless cookie state)"); + tracing::info!("📝 API endpoints available at /api/v1/..."); axum::serve(listener, app).await?; Ok(()) } - -/// Build the application router with appropriate auth layers -#[allow(unused_variables)] // config/user_repo used conditionally based on features -async fn build_app( - state: AppState, - session_layer: SessionManagerLayer, - user_repo: std::sync::Arc, - config: &Config, -) -> anyhow::Result { - let app = Router::new() - .nest("/api/v1", routes::api_v1_router()) - .with_state(state); - - // When auth-axum-login feature is enabled, always apply the auth layer. - // This is needed because: - // 1. OIDC callback uses AuthSession for state management - // 2. Session-based login/register routes use it - // 3. The "JWT mode" just changes what the login endpoint returns, not the underlying session support - #[cfg(feature = "auth-axum-login")] - { - let auth_layer = auth::setup_auth_layer(session_layer, user_repo).await?; - return Ok(app.layer(auth_layer)); - } - - // When auth-axum-login is not compiled in, just use session layer for OIDC flow - #[cfg(not(feature = "auth-axum-login"))] - { - let _ = user_repo; // Suppress unused warning - Ok(app.layer(session_layer)) - } -} - -/// Log authentication info based on enabled features and config -fn log_auth_info(config: &Config) { - match config.auth_mode { - AuthMode::Session => { - tracing::info!("🔒 Authentication mode: Session (cookie-based)"); - } - AuthMode::Jwt => { - tracing::info!("🔒 Authentication mode: JWT (Bearer token)"); - } - AuthMode::Both => { - tracing::info!("🔒 Authentication mode: Both (JWT + Session)"); - } - } - - #[cfg(feature = "auth-axum-login")] - tracing::info!(" ✓ Session auth enabled (axum-login)"); - - #[cfg(feature = "auth-jwt")] - if config.jwt_secret.is_some() { - tracing::info!(" ✓ JWT auth enabled"); - } - - #[cfg(feature = "auth-oidc")] - if config.oidc_issuer.is_some() { - tracing::info!(" ✓ OIDC integration enabled"); - } -} diff --git a/api/src/routes/auth.rs b/api/src/routes/auth.rs index 2e737af..4bfbed5 100644 --- a/api/src/routes/auth.rs +++ b/api/src/routes/auth.rs @@ -1,10 +1,8 @@ //! Authentication routes //! -//! Provides login, register, logout, and token endpoints. -//! Supports both session-based and JWT-based authentication. +//! Provides login, register, logout, token, and OIDC endpoints. +//! All authentication is JWT-based. OIDC state is stored in an encrypted cookie. -#[cfg(feature = "auth-oidc")] -use axum::response::Response; use axum::{ Router, extract::{Json, State}, @@ -12,36 +10,13 @@ use axum::{ response::IntoResponse, routing::{get, post}, }; -use serde::Serialize; -#[cfg(feature = "auth-oidc")] -use tower_sessions::Session; -#[cfg(feature = "auth-axum-login")] -use crate::config::AuthMode; use crate::{ - dto::{LoginRequest, RegisterRequest, UserResponse}, + dto::{LoginRequest, RegisterRequest, TokenResponse, UserResponse}, error::ApiError, extractors::CurrentUser, state::AppState, }; -#[cfg(feature = "auth-axum-login")] -use domain::DomainError; - -/// Token response for JWT authentication -#[derive(Debug, Serialize)] -pub struct TokenResponse { - pub access_token: String, - pub token_type: String, - pub expires_in: u64, -} - -/// Login response that can be either a user (session mode) or a token (JWT mode) -#[derive(Debug, Serialize)] -#[serde(untagged)] -pub enum LoginResponse { - User(UserResponse), - Token(TokenResponse), -} pub fn router() -> Router { let r = Router::new() @@ -50,7 +25,6 @@ pub fn router() -> Router { .route("/logout", post(logout)) .route("/me", get(me)); - // Add token endpoint for getting JWT from session #[cfg(feature = "auth-jwt")] let r = r.route("/token", post(get_token)); @@ -62,171 +36,68 @@ pub fn router() -> Router { r } -/// Login endpoint -/// -/// In session mode: Creates a session and returns user info -/// In JWT mode: Validates credentials and returns a JWT token -/// In both mode: Creates session AND returns JWT token -#[cfg(feature = "auth-axum-login")] +/// Login with email + password → JWT token async fn login( State(state): State, - mut auth_session: crate::auth::AuthSession, Json(payload): Json, ) -> Result { - let user = match auth_session - .authenticate(crate::auth::Credentials { - email: payload.email, - password: payload.password, - }) - .await - .map_err(|e| ApiError::Internal(e.to_string()))? - { - Some(user) => user, - None => return Err(ApiError::Validation("Invalid credentials".to_string())), - }; - - let auth_mode = state.config.auth_mode; - - // In session or both mode, create session - if matches!(auth_mode, AuthMode::Session | AuthMode::Both) { - auth_session - .login(&user) - .await - .map_err(|_| ApiError::Internal("Login failed".to_string()))?; - } - - // In JWT or both mode, return token - #[cfg(feature = "auth-jwt")] - if matches!(auth_mode, AuthMode::Jwt | AuthMode::Both) { - let token = create_jwt_for_user(&user.0, &state)?; - return Ok(( - StatusCode::OK, - Json(LoginResponse::Token(TokenResponse { - access_token: token, - token_type: "Bearer".to_string(), - expires_in: state.config.jwt_expiry_hours * 3600, - })), - )); - } - - // Session mode: return user info - Ok(( - StatusCode::OK, - Json(LoginResponse::User(UserResponse { - id: user.0.id, - email: user.0.email.into_inner(), - created_at: user.0.created_at, - })), - )) -} - -/// Fallback login when auth-axum-login is not enabled -/// Without auth-axum-login, password-based authentication is not available. -/// Use OIDC login instead: GET /api/v1/auth/login/oidc -#[cfg(not(feature = "auth-axum-login"))] -async fn login( - State(_state): State, - Json(_payload): Json, -) -> Result<(StatusCode, Json), ApiError> { - Err(ApiError::Internal( - "Password-based login not available. auth-axum-login feature is required. Use OIDC login at /api/v1/auth/login/oidc instead.".to_string(), - )) -} - -/// Register endpoint -#[cfg(feature = "auth-axum-login")] -async fn register( - State(state): State, - mut auth_session: crate::auth::AuthSession, - Json(payload): Json, -) -> Result { - // Email is already validated by the newtype deserialization - let email = payload.email; - - if state - .user_service - .find_by_email(email.as_ref()) - .await? - .is_some() - { - return Err(ApiError::Domain(DomainError::UserAlreadyExists( - email.as_ref().to_string(), - ))); - } - - // Hash password - let password_hash = infra::auth::backend::hash_password(payload.password.as_ref()); - - // Create user with password let user = state .user_service - .create_local(email.as_ref(), &password_hash) + .find_by_email(payload.email.as_ref()) + .await? + .ok_or_else(|| ApiError::Unauthorized("Invalid credentials".to_string()))?; + + let hash = user + .password_hash + .as_deref() + .ok_or_else(|| ApiError::Unauthorized("Invalid credentials".to_string()))?; + + if !infra::auth::verify_password(payload.password.as_ref(), hash) { + return Err(ApiError::Unauthorized("Invalid credentials".to_string())); + } + + let token = create_jwt(&user, &state)?; + + Ok(( + StatusCode::OK, + Json(TokenResponse { + access_token: token, + token_type: "Bearer".to_string(), + expires_in: state.config.jwt_expiry_hours * 3600, + }), + )) +} + +/// Register a new local user → JWT token +async fn register( + State(state): State, + Json(payload): Json, +) -> Result { + let password_hash = infra::auth::hash_password(payload.password.as_ref()); + + let user = state + .user_service + .create_local(payload.email.as_ref(), &password_hash) .await?; - let auth_mode = state.config.auth_mode; - - // In session or both mode, create session - if matches!(auth_mode, AuthMode::Session | AuthMode::Both) { - let auth_user = crate::auth::AuthUser(user.clone()); - auth_session - .login(&auth_user) - .await - .map_err(|_| ApiError::Internal("Login failed".to_string()))?; - } - - // In JWT or both mode, return token - #[cfg(feature = "auth-jwt")] - if matches!(auth_mode, AuthMode::Jwt | AuthMode::Both) { - let token = create_jwt_for_user(&user, &state)?; - return Ok(( - StatusCode::CREATED, - Json(LoginResponse::Token(TokenResponse { - access_token: token, - token_type: "Bearer".to_string(), - expires_in: state.config.jwt_expiry_hours * 3600, - })), - )); - } + let token = create_jwt(&user, &state)?; Ok(( StatusCode::CREATED, - Json(LoginResponse::User(UserResponse { - id: user.id, - email: user.email.into_inner(), - created_at: user.created_at, - })), + Json(TokenResponse { + access_token: token, + token_type: "Bearer".to_string(), + expires_in: state.config.jwt_expiry_hours * 3600, + }), )) } -/// Fallback register when auth-axum-login is not enabled -#[cfg(not(feature = "auth-axum-login"))] -async fn register( - State(_state): State, - Json(_payload): Json, -) -> Result<(StatusCode, Json), ApiError> { - Err(ApiError::Internal( - "Session-based registration not available. Use JWT token endpoint.".to_string(), - )) -} - -/// Logout endpoint -#[cfg(feature = "auth-axum-login")] -async fn logout(mut auth_session: crate::auth::AuthSession) -> impl IntoResponse { - match auth_session.logout().await { - Ok(_) => StatusCode::OK, - Err(_) => StatusCode::INTERNAL_SERVER_ERROR, - } -} - -/// Fallback logout when auth-axum-login is not enabled -#[cfg(not(feature = "auth-axum-login"))] +/// Logout — JWT is stateless; instruct the client to drop the token async fn logout() -> impl IntoResponse { - // JWT tokens can't be "logged out" server-side without a blocklist - // Just return OK StatusCode::OK } -/// Get current user info +/// Get current user info from JWT async fn me(CurrentUser(user): CurrentUser) -> Result { Ok(Json(UserResponse { id: user.id, @@ -235,15 +106,13 @@ async fn me(CurrentUser(user): CurrentUser) -> Result, CurrentUser(user): CurrentUser, ) -> Result { - let token = create_jwt_for_user(&user, &state)?; + let token = create_jwt(&user, &state)?; Ok(Json(TokenResponse { access_token: token, @@ -252,9 +121,9 @@ async fn get_token( })) } -/// Helper to create JWT for a user +/// Helper: create JWT for a user #[cfg(feature = "auth-jwt")] -fn create_jwt_for_user(user: &domain::User, state: &AppState) -> Result { +fn create_jwt(user: &domain::User, state: &AppState) -> Result { let validator = state .jwt_validator .as_ref() @@ -265,37 +134,54 @@ fn create_jwt_for_user(user: &domain::User, state: &AppState) -> Result Result { + Err(ApiError::Internal("JWT feature not enabled".to_string())) +} + // ============================================================================ // OIDC Routes // ============================================================================ #[cfg(feature = "auth-oidc")] -async fn oidc_login(State(state): State, session: Session) -> Result { +#[derive(serde::Deserialize)] +struct CallbackParams { + code: String, + state: String, +} + +/// Start OIDC login: generate authorization URL and store state in encrypted cookie +#[cfg(feature = "auth-oidc")] +async fn oidc_login( + State(state): State, + jar: axum_extra::extract::PrivateCookieJar, +) -> Result { use axum::http::header; + use axum::response::Response; + use axum_extra::extract::cookie::{Cookie, SameSite}; let service = state .oidc_service .as_ref() .ok_or(ApiError::Internal("OIDC not configured".into()))?; - let auth_data = service.get_authorization_url(); + let (auth_data, oidc_state) = service.get_authorization_url(); - session - .insert("oidc_csrf", &auth_data.csrf_token) - .await - .map_err(|_| ApiError::Internal("Session error".into()))?; - session - .insert("oidc_nonce", &auth_data.nonce) - .await - .map_err(|_| ApiError::Internal("Session error".into()))?; - session - .insert("oidc_pkce", &auth_data.pkce_verifier) - .await - .map_err(|_| ApiError::Internal("Session error".into()))?; + let state_json = serde_json::to_string(&oidc_state) + .map_err(|e| ApiError::Internal(format!("Failed to serialize OIDC state: {}", e)))?; - let response = axum::response::Redirect::to(auth_data.url.as_str()).into_response(); - let (mut parts, body) = response.into_parts(); + let cookie = Cookie::build(("oidc_state", state_json)) + .max_age(time::Duration::minutes(5)) + .http_only(true) + .same_site(SameSite::Lax) + .secure(state.config.secure_cookie) + .path("/") + .build(); + let updated_jar = jar.add(cookie); + + let redirect = axum::response::Redirect::to(auth_data.url.as_str()).into_response(); + let (mut parts, body) = redirect.into_parts(); parts.headers.insert( header::CACHE_CONTROL, "no-cache, no-store, must-revalidate".parse().unwrap(), @@ -305,54 +191,42 @@ async fn oidc_login(State(state): State, session: Session) -> Result, - session: Session, - mut auth_session: crate::auth::AuthSession, + jar: axum_extra::extract::PrivateCookieJar, axum::extract::Query(params): axum::extract::Query, ) -> Result { + use infra::auth::oidc::OidcState; + let service = state .oidc_service .as_ref() .ok_or(ApiError::Internal("OIDC not configured".into()))?; - let stored_csrf: domain::CsrfToken = session - .get("oidc_csrf") - .await - .map_err(|_| ApiError::Internal("Session error".into()))? - .ok_or(ApiError::Validation("Missing CSRF token".into()))?; + // Read and decrypt OIDC state from cookie + let cookie = jar + .get("oidc_state") + .ok_or(ApiError::Validation("Missing OIDC state cookie".into()))?; - if params.state != stored_csrf.as_ref() { + let oidc_state: OidcState = serde_json::from_str(cookie.value()) + .map_err(|_| ApiError::Validation("Invalid OIDC state cookie".into()))?; + + // Verify CSRF token + if params.state != oidc_state.csrf_token.as_ref() { return Err(ApiError::Validation("Invalid CSRF token".into())); } - let stored_pkce: domain::PkceVerifier = session - .get("oidc_pkce") - .await - .map_err(|_| ApiError::Internal("Session error".into()))? - .ok_or(ApiError::Validation("Missing PKCE".into()))?; - let stored_nonce: domain::OidcNonce = session - .get("oidc_nonce") - .await - .map_err(|_| ApiError::Internal("Session error".into()))? - .ok_or(ApiError::Validation("Missing Nonce".into()))?; - + // Complete OIDC exchange let oidc_user = service .resolve_callback( domain::AuthorizationCode::new(params.code), - stored_nonce, - stored_pkce, + oidc_state.nonce, + oidc_state.pkce_verifier, ) .await .map_err(|e| ApiError::Internal(e.to_string()))?; @@ -363,129 +237,17 @@ async fn oidc_callback( .await .map_err(|e| ApiError::Internal(e.to_string()))?; - let auth_mode = state.config.auth_mode; + // Clear the OIDC state cookie + let cleared_jar = jar.remove(axum_extra::extract::cookie::Cookie::from("oidc_state")); - // In session or both mode, create session - if matches!(auth_mode, AuthMode::Session | AuthMode::Both) { - auth_session - .login(&crate::auth::AuthUser(user.clone())) - .await - .map_err(|_| ApiError::Internal("Login failed".into()))?; - } + let token = create_jwt(&user, &state)?; - // Clean up OIDC state - let _: Option = session - .remove("oidc_csrf") - .await - .map_err(|_| ApiError::Internal("Session error".into()))?; - let _: Option = session - .remove("oidc_pkce") - .await - .map_err(|_| ApiError::Internal("Session error".into()))?; - let _: Option = session - .remove("oidc_nonce") - .await - .map_err(|_| ApiError::Internal("Session error".into()))?; - - // In JWT mode, return token as JSON - #[cfg(feature = "auth-jwt")] - if matches!(auth_mode, AuthMode::Jwt | AuthMode::Both) { - let token = create_jwt_for_user(&user, &state)?; - return Ok(Json(TokenResponse { + Ok(( + cleared_jar, + Json(TokenResponse { access_token: token, token_type: "Bearer".to_string(), expires_in: state.config.jwt_expiry_hours * 3600, - }) - .into_response()); - } - - // Session mode: return user info - Ok(Json(UserResponse { - id: user.id, - email: user.email.into_inner(), - created_at: user.created_at, - }) - .into_response()) -} - -/// Fallback OIDC callback when auth-axum-login is not enabled -#[cfg(all(feature = "auth-oidc", not(feature = "auth-axum-login")))] -async fn oidc_callback( - State(state): State, - session: Session, - axum::extract::Query(params): axum::extract::Query, -) -> Result { - let service = state - .oidc_service - .as_ref() - .ok_or(ApiError::Internal("OIDC not configured".into()))?; - - let stored_csrf: domain::CsrfToken = session - .get("oidc_csrf") - .await - .map_err(|_| ApiError::Internal("Session error".into()))? - .ok_or(ApiError::Validation("Missing CSRF token".into()))?; - - if params.state != stored_csrf.as_ref() { - return Err(ApiError::Validation("Invalid CSRF token".into())); - } - - let stored_pkce: domain::PkceVerifier = session - .get("oidc_pkce") - .await - .map_err(|_| ApiError::Internal("Session error".into()))? - .ok_or(ApiError::Validation("Missing PKCE".into()))?; - let stored_nonce: domain::OidcNonce = session - .get("oidc_nonce") - .await - .map_err(|_| ApiError::Internal("Session error".into()))? - .ok_or(ApiError::Validation("Missing Nonce".into()))?; - - let oidc_user = service - .resolve_callback( - domain::AuthorizationCode::new(params.code), - stored_nonce, - stored_pkce, - ) - .await - .map_err(|e| ApiError::Internal(e.to_string()))?; - - let user = state - .user_service - .find_or_create(&oidc_user.subject, &oidc_user.email) - .await - .map_err(|e| ApiError::Internal(e.to_string()))?; - - // Clean up OIDC state - let _: Option = session - .remove("oidc_csrf") - .await - .map_err(|_| ApiError::Internal("Session error".into()))?; - let _: Option = session - .remove("oidc_pkce") - .await - .map_err(|_| ApiError::Internal("Session error".into()))?; - let _: Option = session - .remove("oidc_nonce") - .await - .map_err(|_| ApiError::Internal("Session error".into()))?; - - // Return token as JSON - #[cfg(feature = "auth-jwt")] - { - let token = create_jwt_for_user(&user, &state)?; - return Ok(Json(TokenResponse { - access_token: token, - token_type: "Bearer".to_string(), - expires_in: state.config.jwt_expiry_hours * 3600, - })); - } - - #[cfg(not(feature = "auth-jwt"))] - { - let _ = user; // Suppress unused warning - Err(ApiError::Internal( - "No auth backend available for OIDC callback".to_string(), - )) - } + }), + )) } diff --git a/api/src/state.rs b/api/src/state.rs index 8889482..ecdce4d 100644 --- a/api/src/state.rs +++ b/api/src/state.rs @@ -3,18 +3,20 @@ //! Holds shared state for the application. use axum::extract::FromRef; +use axum_extra::extract::cookie::Key; #[cfg(feature = "auth-jwt")] use infra::auth::jwt::{JwtConfig, JwtValidator}; #[cfg(feature = "auth-oidc")] use infra::auth::oidc::OidcService; use std::sync::Arc; -use crate::config::{AuthMode, Config}; +use crate::config::Config; use domain::UserService; #[derive(Clone)] pub struct AppState { pub user_service: Arc, + pub cookie_key: Key, #[cfg(feature = "auth-oidc")] pub oidc_service: Option>, #[cfg(feature = "auth-jwt")] @@ -24,6 +26,8 @@ pub struct AppState { impl AppState { pub async fn new(user_service: UserService, config: Config) -> anyhow::Result { + let cookie_key = Key::derive_from(config.cookie_secret.as_bytes()); + #[cfg(feature = "auth-oidc")] let oidc_service = if let (Some(issuer), Some(id), secret, Some(redirect), resource_id) = ( &config.oidc_issuer, @@ -34,7 +38,6 @@ impl AppState { ) { tracing::info!("Initializing OIDC service with issuer: {}", issuer); - // Construct newtypes from config strings let issuer_url = domain::IssuerUrl::new(issuer) .map_err(|e| anyhow::anyhow!("Invalid OIDC issuer URL: {}", e))?; let client_id = domain::ClientId::new(id) @@ -57,25 +60,15 @@ impl AppState { }; #[cfg(feature = "auth-jwt")] - let jwt_validator = if matches!(config.auth_mode, AuthMode::Jwt | AuthMode::Both) { - // Use provided secret or fall back to a development secret - let secret = if let Some(ref s) = config.jwt_secret { - if s.is_empty() { None } else { Some(s.clone()) } - } else { - None - }; - - let secret = match secret { - Some(s) => s, - None => { + let jwt_validator = { + let secret = match &config.jwt_secret { + Some(s) if !s.is_empty() => s.clone(), + _ => { if config.is_production { - anyhow::bail!( - "JWT_SECRET is required when AUTH_MODE is 'jwt' or 'both' in production" - ); + anyhow::bail!("JWT_SECRET is required in production"); } - // Use a development-only default secret tracing::warn!( - "⚠️ JWT_SECRET not set - using insecure development secret. DO NOT USE IN PRODUCTION!" + "⚠️ JWT_SECRET not set — using insecure development secret. DO NOT USE IN PRODUCTION!" ); "k-template-dev-secret-not-for-production-use-only".to_string() } @@ -90,12 +83,11 @@ impl AppState { config.is_production, )?; Some(Arc::new(JwtValidator::new(jwt_config))) - } else { - None }; Ok(Self { user_service: Arc::new(user_service), + cookie_key, #[cfg(feature = "auth-oidc")] oidc_service, #[cfg(feature = "auth-jwt")] @@ -116,3 +108,9 @@ impl FromRef for Arc { input.config.clone() } } + +impl FromRef for Key { + fn from_ref(input: &AppState) -> Self { + input.cookie_key.clone() + } +} diff --git a/cargo-generate.toml b/cargo-generate.toml index bf14f35..d1d9a48 100644 --- a/cargo-generate.toml +++ b/cargo-generate.toml @@ -24,11 +24,6 @@ prompt = "Database type" choices = ["sqlite", "postgres"] default = "sqlite" -[placeholders.auth_session] -type = "bool" -prompt = "Enable session-based authentication (cookies)?" -default = true - [placeholders.auth_jwt] type = "bool" prompt = "Enable JWT authentication (Bearer tokens)?" diff --git a/domain/Cargo.toml b/domain/Cargo.toml index 05fff94..82e7556 100644 --- a/domain/Cargo.toml +++ b/domain/Cargo.toml @@ -4,17 +4,13 @@ version = "0.1.0" edition = "2024" [dependencies] -anyhow = "1.0.100" async-trait = "0.1.89" chrono = { version = "0.4.42", features = ["serde"] } email_address = "0.2" serde = { version = "1.0.228", features = ["derive"] } -serde_json = "1.0.146" thiserror = "2.0.17" -tracing = "0.1" url = { version = "2.5", features = ["serde"] } uuid = { version = "1.19.0", features = ["v4", "serde"] } -futures-core = "0.3" [dev-dependencies] tokio = { version = "1", features = ["rt", "macros"] } diff --git a/domain/src/entities.rs b/domain/src/entities.rs index 1da6979..593fb57 100644 --- a/domain/src/entities.rs +++ b/domain/src/entities.rs @@ -57,8 +57,4 @@ impl User { } } - /// Helper to get email as string - pub fn email_str(&self) -> &str { - self.email.as_ref() - } } diff --git a/domain/src/errors.rs b/domain/src/errors.rs index bcc697e..1add194 100644 --- a/domain/src/errors.rs +++ b/domain/src/errors.rs @@ -9,6 +9,7 @@ use uuid::Uuid; /// Domain-level errors for K-Notes operations #[derive(Debug, Error)] +#[non_exhaustive] pub enum DomainError { /// The requested user was not found #[error("User not found: {0}")] @@ -22,9 +23,13 @@ pub enum DomainError { #[error("Validation error: {0}")] ValidationError(String), - /// User is not authorized to perform this action - #[error("Unauthorized: {0}")] - Unauthorized(String), + /// User is not authenticated (maps to HTTP 401) + #[error("Unauthenticated: {0}")] + Unauthenticated(String), + + /// User is not allowed to perform this action (maps to HTTP 403) + #[error("Forbidden: {0}")] + Forbidden(String), /// A repository/infrastructure error occurred #[error("Repository error: {0}")] @@ -41,9 +46,14 @@ impl DomainError { Self::ValidationError(message.into()) } - /// Create an unauthorized error - pub fn unauthorized(message: impl Into) -> Self { - Self::Unauthorized(message.into()) + /// Create an unauthenticated error (not logged in → 401) + pub fn unauthenticated(message: impl Into) -> Self { + Self::Unauthenticated(message.into()) + } + + /// Create a forbidden error (not allowed → 403) + pub fn forbidden(message: impl Into) -> Self { + Self::Forbidden(message.into()) } /// Check if this error indicates a "not found" condition diff --git a/domain/src/repositories.rs b/domain/src/repositories.rs index 5534a78..7b7c911 100644 --- a/domain/src/repositories.rs +++ b/domain/src/repositories.rs @@ -1,5 +1,5 @@ //! Reference Repository ports (traits) -//! +//! //! These traits define the interface for data persistence. use async_trait::async_trait; diff --git a/domain/src/value_objects.rs b/domain/src/value_objects.rs index 93b67e6..0cf4586 100644 --- a/domain/src/value_objects.rs +++ b/domain/src/value_objects.rs @@ -17,6 +17,7 @@ pub type UserId = Uuid; /// Errors that occur when parsing/validating value objects #[derive(Debug, Error, Clone, PartialEq, Eq)] +#[non_exhaustive] pub enum ValidationError { #[error("Invalid email format: {0}")] InvalidEmail(String), @@ -109,8 +110,8 @@ impl<'de> Deserialize<'de> for Email { #[derive(Clone, PartialEq, Eq)] pub struct Password(String); -/// Minimum password length -pub const MIN_PASSWORD_LENGTH: usize = 6; +/// Minimum password length (NIST recommendation) +pub const MIN_PASSWORD_LENGTH: usize = 8; impl Password { pub fn new(value: impl Into) -> Result { @@ -497,82 +498,6 @@ pub struct AuthorizationUrlData { // Configuration Newtypes // ============================================================================ -/// Database connection URL -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(try_from = "String", into = "String")] -pub struct DatabaseUrl(String); - -impl DatabaseUrl { - pub fn new(value: impl Into) -> Result { - let value = value.into(); - if value.trim().is_empty() { - return Err(ValidationError::Empty("database_url".to_string())); - } - Ok(Self(value)) - } -} - -impl AsRef for DatabaseUrl { - fn as_ref(&self) -> &str { - &self.0 - } -} - -impl fmt::Display for DatabaseUrl { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } -} - -impl TryFrom for DatabaseUrl { - type Error = ValidationError; - fn try_from(value: String) -> Result { - Self::new(value) - } -} - -impl From for String { - fn from(val: DatabaseUrl) -> Self { - val.0 - } -} - -/// Session secret with minimum length requirement -pub const MIN_SESSION_SECRET_LENGTH: usize = 64; - -#[derive(Clone, PartialEq, Eq)] -pub struct SessionSecret(String); - -impl SessionSecret { - pub fn new(value: impl Into) -> Result { - let value = value.into(); - if value.len() < MIN_SESSION_SECRET_LENGTH { - return Err(ValidationError::SecretTooShort { - min: MIN_SESSION_SECRET_LENGTH, - actual: value.len(), - }); - } - Ok(Self(value)) - } - - /// Create without validation (for development/testing) - pub fn new_unchecked(value: impl Into) -> Self { - Self(value.into()) - } -} - -impl AsRef for SessionSecret { - fn as_ref(&self) -> &str { - &self.0 - } -} - -impl fmt::Debug for SessionSecret { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "SessionSecret(***)") - } -} - /// JWT signing secret with minimum length requirement pub const MIN_JWT_SECRET_LENGTH: usize = 32; @@ -655,12 +580,12 @@ mod tests { #[test] fn test_valid_password() { assert!(Password::new("secret123").is_ok()); - assert!(Password::new("123456").is_ok()); // Exactly 6 chars + assert!(Password::new("12345678").is_ok()); // Exactly 8 chars } #[test] fn test_password_too_short() { - assert!(Password::new("12345").is_err()); // 5 chars + assert!(Password::new("1234567").is_err()); // 7 chars assert!(Password::new("").is_err()); } @@ -705,15 +630,6 @@ mod tests { mod secret_tests { use super::*; - #[test] - fn test_session_secret_min_length() { - let short = "short"; - let long = "a".repeat(64); - - assert!(SessionSecret::new(short).is_err()); - assert!(SessionSecret::new(long).is_ok()); - } - #[test] fn test_jwt_secret_production_check() { let short = "short"; @@ -729,10 +645,7 @@ mod tests { #[test] fn test_secrets_hide_in_debug() { - let session = SessionSecret::new_unchecked("secret"); let jwt = JwtSecret::new_unchecked("secret"); - - assert!(!format!("{:?}", session).contains("secret")); assert!(!format!("{:?}", jwt).contains("secret")); } } diff --git a/infra/Cargo.toml b/infra/Cargo.toml index d37d898..d2a6367 100644 --- a/infra/Cargo.toml +++ b/infra/Cargo.toml @@ -5,28 +5,16 @@ edition = "2024" [features] default = ["sqlite"] -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", -] +sqlite = ["sqlx/sqlite", "k-core/sqlite"] +postgres = ["sqlx/postgres", "k-core/postgres"] 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-oidc = ["dep:openidconnect", "dep:url", "dep:axum-extra"] auth-jwt = ["dep:jsonwebtoken"] [dependencies] k-core = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-core", features = [ "logging", "db-sqlx", - "sessions-db", ] } domain = { path = "../domain" } @@ -38,19 +26,17 @@ anyhow = "1.0" tokio = { version = "1.48.0", features = ["full"] } tracing = "0.1" uuid = { version = "1.19.0", features = ["v4", "serde"] } -tower-sessions-sqlx-store = { version = "0.15.0", optional = true } serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } +futures-core = "0.3" +password-auth = "1.0" + +# Optional dependencies async-nats = { version = "0.45", optional = true } futures-util = { version = "0.3", optional = true } -futures-core = "0.3" -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 } +axum-extra = { version = "0.10", features = ["cookie-private"], optional = true } jsonwebtoken = { version = "10.2.0", features = [ "sha2", "p256", diff --git a/infra/Cargo.toml.template b/infra/Cargo.toml.template index 8da6944..f8cbc1b 100644 --- a/infra/Cargo.toml.template +++ b/infra/Cargo.toml.template @@ -4,29 +4,17 @@ version = "0.1.0" edition = "2024" [features] -default = ["{{database}}"{% if auth_session %}, "auth-axum-login"{% endif %}{% if auth_oidc %}, "auth-oidc"{% endif %}{% if auth_jwt %}, "auth-jwt"{% endif %}] -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 = ["{{database}}"{% if auth_oidc %}, "auth-oidc"{% endif %}{% if auth_jwt %}, "auth-jwt"{% endif %}] +sqlite = ["sqlx/sqlite", "k-core/sqlite"] +postgres = ["sqlx/postgres", "k-core/postgres"] 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-oidc = ["dep:openidconnect", "dep:url", "dep:axum-extra"] auth-jwt = ["dep:jsonwebtoken"] [dependencies] k-core = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-core", features = [ "logging", "db-sqlx", - "sessions-db", ] } domain = { path = "../domain" } @@ -38,18 +26,15 @@ anyhow = "1.0" tokio = { version = "1.48.0", features = ["full"] } tracing = "0.1" uuid = { version = "1.19.0", features = ["v4", "serde"] } -tower-sessions-sqlx-store = { version = "0.15.0", optional = true } serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } +futures-core = "0.3" +password-auth = "1.0" + +# Optional dependencies async-nats = { version = "0.45", optional = true } futures-util = { version = "0.3", optional = true } -futures-core = "0.3" -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 } +axum-extra = { version = "0.10", features = ["cookie-private"], optional = true } jsonwebtoken = { version = "9.3", optional = true } -# reqwest = { version = "0.13.1", features = ["blocking", "json"], optional = true } diff --git a/infra/src/auth/mod.rs b/infra/src/auth/mod.rs index 8e7fe9e..c2ef8ed 100644 --- a/infra/src/auth/mod.rs +++ b/infra/src/auth/mod.rs @@ -2,122 +2,14 @@ //! //! This module contains the concrete implementation of authentication mechanisms. -#[cfg(feature = "auth-axum-login")] -pub mod backend { - use std::sync::Arc; +/// Hash a password using the password-auth crate +pub fn hash_password(password: &str) -> String { + password_auth::generate_hash(password) +} - use axum_login::{AuthnBackend, UserId}; - use password_auth::verify_password; - use serde::{Deserialize, Serialize}; - use tower_sessions::SessionManagerLayer; - use uuid::Uuid; - - use domain::{User, UserRepository}; - - // We use the same session store as defined in infra - 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: domain::Email, - pub password: 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) - } +/// Verify a password against a stored hash +pub fn verify_password(password: &str, hash: &str) -> bool { + password_auth::verify_password(password, hash).is_ok() } #[cfg(feature = "auth-oidc")] diff --git a/infra/src/auth/oidc.rs b/infra/src/auth/oidc.rs index feff062..6dcfdec 100644 --- a/infra/src/auth/oidc.rs +++ b/infra/src/auth/oidc.rs @@ -15,6 +15,7 @@ use openidconnect::{ }, reqwest, }; +use serde::{Deserialize, Serialize}; pub type OidcClient = Client< EmptyAdditionalClaims, @@ -36,9 +37,18 @@ pub type OidcClient = Client< EndpointMaybeSet, // HasUserInfoUrl (Discovered, might be missing) >; +/// Serializable OIDC state stored in an encrypted cookie during the auth code flow +#[derive(Debug, Serialize, Deserialize)] +pub struct OidcState { + pub csrf_token: CsrfToken, + pub nonce: OidcNonce, + pub pkce_verifier: PkceVerifier, +} + #[derive(Clone)] pub struct OidcService { client: OidcClient, + http_client: reqwest::Client, resource_id: Option, } @@ -61,11 +71,7 @@ impl OidcService { tracing::debug!("🔵 OIDC Setup: Redirect = '{}'", redirect_url); tracing::debug!( "🔵 OIDC Setup: Secret = {:?}", - if client_secret.is_some() { - "SET" - } else { - "NONE" - } + if client_secret.is_some() { "SET" } else { "NONE" } ); let http_client = reqwest::ClientBuilder::new() @@ -78,13 +84,13 @@ impl OidcService { ) .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 oidc_redirect_url = + openidconnect::RedirectUrl::new(redirect_url.as_ref().to_string())?; let client = CoreClient::from_provider_metadata( provider_metadata, @@ -95,14 +101,16 @@ impl OidcService { Ok(Self { client, + http_client, resource_id, }) } - /// Get the authorization URL and associated state for OIDC login + /// 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 { + /// Returns `(AuthorizationUrlData, OidcState)` where `OidcState` should be + /// serialized and stored in an encrypted cookie for the duration of the flow. + pub fn get_authorization_url(&self) -> (AuthorizationUrlData, OidcState) { let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); let (auth_url, csrf_token, nonce) = self @@ -117,12 +125,20 @@ impl OidcService { .set_pkce_challenge(pkce_challenge) .url(); - AuthorizationUrlData { - url: auth_url.into(), + let oidc_state = OidcState { 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()), - } + }; + + let auth_data = AuthorizationUrlData { + url: auth_url.into(), + csrf_token: oidc_state.csrf_token.clone(), + nonce: oidc_state.nonce.clone(), + pkce_verifier: oidc_state.pkce_verifier.clone(), + }; + + (auth_data, oidc_state) } /// Resolve the OIDC callback with type-safe parameters @@ -132,10 +148,6 @@ impl OidcService { 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()); @@ -146,7 +158,7 @@ impl OidcService { code.as_ref().to_string(), ))? .set_pkce_verifier(oidc_pkce_verifier) - .request_async(&http_client) + .request_async(&self.http_client) .await?; let id_token = token_response @@ -178,19 +190,17 @@ impl OidcService { 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) + .request_async(&self.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"))?; diff --git a/infra/src/factory.rs b/infra/src/factory.rs index 388e984..7d3b4c1 100644 --- a/infra/src/factory.rs +++ b/infra/src/factory.rs @@ -5,8 +5,6 @@ use crate::SqliteUserRepository; use crate::db::DatabasePool; use domain::UserRepository; -use k_core::session::store::InfraSessionStore; - #[derive(Debug, thiserror::Error)] pub enum FactoryError { #[error("Database error: {0}")] @@ -33,18 +31,3 @@ pub async fn build_user_repository(pool: &DatabasePool) -> FactoryResult FactoryResult { - Ok(match pool { - #[cfg(feature = "sqlite")] - DatabasePool::Sqlite(p) => { - InfraSessionStore::Sqlite(tower_sessions_sqlx_store::SqliteStore::new(p.clone())) - } - #[cfg(feature = "postgres")] - DatabasePool::Postgres(p) => { - InfraSessionStore::Postgres(tower_sessions_sqlx_store::PostgresStore::new(p.clone())) - } - }) -} diff --git a/infra/src/lib.rs b/infra/src/lib.rs index 53dbbab..12b4453 100644 --- a/infra/src/lib.rs +++ b/infra/src/lib.rs @@ -5,9 +5,7 @@ //! //! ## Adapters //! -//! - [`SqliteNoteRepository`] - SQLite adapter for notes with FTS5 search //! - [`SqliteUserRepository`] - SQLite adapter for users (OIDC-ready) -//! - [`SqliteTagRepository`] - SQLite adapter for tags //! //! ## Database //! @@ -17,7 +15,6 @@ pub mod auth; pub mod db; pub mod factory; -pub mod session_store; mod user_repository; // Re-export for convenience diff --git a/infra/src/session_store.rs b/infra/src/session_store.rs deleted file mode 100644 index edb657f..0000000 --- a/infra/src/session_store.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub use k_core::session::store::InfraSessionStore; -pub use tower_sessions::{Expiry, SessionManagerLayer}; diff --git a/infra/src/user_repository.rs b/infra/src/user_repository.rs index a2cfec6..4038adb 100644 --- a/infra/src/user_repository.rs +++ b/infra/src/user_repository.rs @@ -1,27 +1,13 @@ -//! SQLite implementation of UserRepository +//! SQLite and PostgreSQL implementations of UserRepository use async_trait::async_trait; use chrono::{DateTime, Utc}; -use sqlx::{FromRow, SqlitePool}; +use sqlx::FromRow; use uuid::Uuid; use domain::{DomainError, DomainResult, Email, User, UserRepository}; -/// SQLite adapter for UserRepository -#[cfg(feature = "sqlite")] -#[derive(Clone)] -pub struct SqliteUserRepository { - pool: SqlitePool, -} - -#[cfg(feature = "sqlite")] -impl SqliteUserRepository { - pub fn new(pool: SqlitePool) -> Self { - Self { pool } - } -} - -/// Row type for SQLite query results +/// Row type for database query results (shared between SQLite and PostgreSQL) #[derive(Debug, FromRow)] struct UserRow { id: String, @@ -46,7 +32,6 @@ impl TryFrom for User { }) .map_err(|e| DomainError::RepositoryError(format!("Invalid datetime: {}", e)))?; - // Parse email from string - it was validated when originally stored let email = Email::try_from(row.email) .map_err(|e| DomainError::RepositoryError(format!("Invalid email in DB: {}", e)))?; @@ -60,6 +45,20 @@ impl TryFrom for User { } } +/// SQLite adapter for UserRepository +#[cfg(feature = "sqlite")] +#[derive(Clone)] +pub struct SqliteUserRepository { + pool: sqlx::SqlitePool, +} + +#[cfg(feature = "sqlite")] +impl SqliteUserRepository { + pub fn new(pool: sqlx::SqlitePool) -> Self { + Self { pool } + } +} + #[cfg(feature = "sqlite")] #[async_trait] impl UserRepository for SqliteUserRepository { @@ -116,12 +115,20 @@ impl UserRepository for SqliteUserRepository { ) .bind(&id) .bind(&user.subject) - .bind(user.email.as_ref()) // Use .as_ref() to get the inner &str + .bind(user.email.as_ref()) .bind(&user.password_hash) .bind(&created_at) .execute(&self.pool) .await - .map_err(|e| DomainError::RepositoryError(e.to_string()))?; + .map_err(|e| { + // Surface UNIQUE constraint violations as domain-level conflicts + let msg = e.to_string(); + if msg.contains("UNIQUE constraint failed") || msg.contains("unique constraint") { + DomainError::UserAlreadyExists(user.email.as_ref().to_string()) + } else { + DomainError::RepositoryError(msg) + } + })?; Ok(()) } @@ -144,7 +151,7 @@ mod tests { use crate::db::run_migrations; use k_core::db::{DatabaseConfig, DatabasePool, connect}; - async fn setup_test_db() -> SqlitePool { + async fn setup_test_db() -> sqlx::SqlitePool { let config = DatabaseConfig::default(); let db_pool = connect(&config).await.expect("Failed to create pool"); @@ -168,7 +175,7 @@ mod tests { assert!(found.is_some()); let found = found.unwrap(); assert_eq!(found.subject, "oidc|123"); - assert_eq!(found.email_str(), "test@example.com"); + assert_eq!(found.email.as_ref(), "test@example.com"); assert!(found.password_hash.is_none()); } @@ -184,7 +191,7 @@ mod tests { let found = repo.find_by_id(user.id).await.unwrap(); assert!(found.is_some()); let found = found.unwrap(); - assert_eq!(found.email_str(), "local@example.com"); + assert_eq!(found.email.as_ref(), "local@example.com"); assert_eq!(found.password_hash, Some("hashed_pw".to_string())); } @@ -292,7 +299,14 @@ impl UserRepository for PostgresUserRepository { .bind(&created_at) .execute(&self.pool) .await - .map_err(|e| DomainError::RepositoryError(e.to_string()))?; + .map_err(|e| { + let msg = e.to_string(); + if msg.contains("unique constraint") || msg.contains("duplicate key") { + DomainError::UserAlreadyExists(user.email.as_ref().to_string()) + } else { + DomainError::RepositoryError(msg) + } + })?; Ok(()) }