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.
This commit is contained in:
2026-03-05 01:28:27 +01:00
parent c368293cd4
commit 9ca4eeddb4
25 changed files with 440 additions and 1340 deletions

435
Cargo.lock generated
View File

@@ -2,6 +2,41 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 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]] [[package]]
name = "aho-corasick" name = "aho-corasick"
version = "1.1.4" version = "1.1.4"
@@ -37,10 +72,9 @@ name = "api"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait",
"axum", "axum",
"axum-extra",
"chrono", "chrono",
"config",
"domain", "domain",
"dotenvy", "dotenvy",
"infra", "infra",
@@ -52,10 +86,7 @@ dependencies = [
"tokio", "tokio",
"tower", "tower",
"tower-http", "tower-http",
"tower-sessions",
"tower-sessions-sqlx-store",
"tracing", "tracing",
"tracing-subscriber",
"uuid", "uuid",
] ]
@@ -71,12 +102,6 @@ dependencies = [
"password-hash", "password-hash",
] ]
[[package]]
name = "arraydeque"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
[[package]] [[package]]
name = "async-nats" name = "async-nats"
version = "0.45.0" version = "0.45.0"
@@ -200,22 +225,26 @@ dependencies = [
] ]
[[package]] [[package]]
name = "axum-login" name = "axum-extra"
version = "0.18.0" version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "964ea6eb764a227baa8c3368e45c94d23b6863cc7b880c6c9e341c143c5a5ff7" checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96"
dependencies = [ dependencies = [
"axum", "axum",
"form_urlencoded", "axum-core",
"serde", "bytes",
"subtle", "cookie",
"thiserror 2.0.17", "futures-util",
"tower-cookies", "http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"rustversion",
"serde_core",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tower-sessions",
"tracing", "tracing",
"urlencoding",
] ]
[[package]] [[package]]
@@ -337,6 +366,16 @@ dependencies = [
"windows-link", "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]] [[package]]
name = "concurrent-queue" name = "concurrent-queue"
version = "2.5.0" version = "2.5.0"
@@ -346,68 +385,25 @@ dependencies = [
"crossbeam-utils", "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]] [[package]]
name = "const-oid" name = "const-oid"
version = "0.9.6" version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" 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]] [[package]]
name = "cookie" name = "cookie"
version = "0.18.1" version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
dependencies = [ dependencies = [
"aes-gcm",
"base64 0.22.1",
"hkdf",
"percent-encoding", "percent-encoding",
"rand 0.8.5",
"sha2",
"subtle",
"time", "time",
"version_check", "version_check",
] ]
@@ -467,12 +463,6 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]] [[package]]
name = "crypto-bigint" name = "crypto-bigint"
version = "0.5.5" version = "0.5.5"
@@ -492,9 +482,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [ dependencies = [
"generic-array", "generic-array",
"rand_core 0.6.4",
"typenum", "typenum",
] ]
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher",
]
[[package]] [[package]]
name = "curve25519-dalek" name = "curve25519-dalek"
version = "4.1.3" version = "4.1.3"
@@ -607,29 +607,16 @@ dependencies = [
"syn", "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]] [[package]]
name = "domain" name = "domain"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow",
"async-trait", "async-trait",
"chrono", "chrono",
"email_address", "email_address",
"futures-core",
"serde", "serde",
"serde_json",
"thiserror 2.0.17", "thiserror 2.0.17",
"tokio", "tokio",
"tracing",
"url", "url",
"uuid", "uuid",
] ]
@@ -724,32 +711,12 @@ dependencies = [
"serde", "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]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 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]] [[package]]
name = "errno" name = "errno"
version = "0.3.14" version = "0.3.14"
@@ -972,6 +939,16 @@ dependencies = [
"wasm-bindgen", "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]] [[package]]
name = "group" name = "group"
version = "0.13.0" version = "0.13.0"
@@ -989,12 +966,6 @@ version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.15.5" version = "0.15.5"
@@ -1330,7 +1301,7 @@ dependencies = [
"anyhow", "anyhow",
"async-nats", "async-nats",
"async-trait", "async-trait",
"axum-login", "axum-extra",
"chrono", "chrono",
"domain", "domain",
"futures-core", "futures-core",
@@ -1344,13 +1315,20 @@ dependencies = [
"sqlx", "sqlx",
"thiserror 2.0.17", "thiserror 2.0.17",
"tokio", "tokio",
"tower-sessions",
"tower-sessions-sqlx-store",
"tracing", "tracing",
"url", "url",
"uuid", "uuid",
] ]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]] [[package]]
name = "ipnet" name = "ipnet"
version = "2.11.0" version = "2.11.0"
@@ -1392,17 +1370,6 @@ dependencies = [
"wasm-bindgen", "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]] [[package]]
name = "jsonwebtoken" name = "jsonwebtoken"
version = "10.2.0" version = "10.2.0"
@@ -1692,6 +1659,12 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]] [[package]]
name = "openidconnect" name = "openidconnect"
version = "4.0.1" version = "4.0.1"
@@ -1738,16 +1711,6 @@ dependencies = [
"num-traits", "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]] [[package]]
name = "p256" name = "p256"
version = "0.13.2" version = "0.13.2"
@@ -1824,12 +1787,6 @@ dependencies = [
"subtle", "subtle",
] ]
[[package]]
name = "pathdiff"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]] [[package]]
name = "pem" name = "pem"
version = "3.0.6" version = "3.0.6"
@@ -1855,49 +1812,6 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" 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]] [[package]]
name = "pin-project" name = "pin-project"
version = "1.1.10" version = "1.1.10"
@@ -1957,6 +1871,18 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" 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]] [[package]]
name = "portable-atomic" name = "portable-atomic"
version = "1.13.0" version = "1.13.0"
@@ -2282,20 +2208,6 @@ dependencies = [
"serde", "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]] [[package]]
name = "rsa" name = "rsa"
version = "0.9.9" version = "0.9.9"
@@ -2316,16 +2228,6 @@ dependencies = [
"zeroize", "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]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "2.1.1" version = "2.1.1"
@@ -2512,18 +2414,6 @@ dependencies = [
"serde_derive", "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]] [[package]]
name = "serde-value" name = "serde-value"
version = "0.7.0" version = "0.7.0"
@@ -2607,15 +2497,6 @@ dependencies = [
"syn", "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]] [[package]]
name = "serde_urlencoded" name = "serde_urlencoded"
version = "0.7.1" version = "0.7.1"
@@ -3124,15 +3005,6 @@ dependencies = [
"time-core", "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]] [[package]]
name = "tinystr" name = "tinystr"
version = "0.8.2" version = "0.8.2"
@@ -3241,37 +3113,6 @@ dependencies = [
"webpki-roots 0.26.11", "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]] [[package]]
name = "tower" name = "tower"
version = "0.5.2" version = "0.5.2"
@@ -3478,24 +3319,12 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "typeid"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
[[package]] [[package]]
name = "typenum" name = "typenum"
version = "1.19.0" version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "ucd-trie"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
[[package]] [[package]]
name = "unicode-bidi" name = "unicode-bidi"
version = "0.3.18" version = "0.3.18"
@@ -3524,10 +3353,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
[[package]] [[package]]
name = "unicode-segmentation" name = "universal-hash"
version = "1.12.0" version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"subtle",
]
[[package]] [[package]]
name = "untrusted" name = "untrusted"
@@ -3548,12 +3381,6 @@ dependencies = [
"serde_derive", "serde_derive",
] ]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]] [[package]]
name = "utf8_iter" name = "utf8_iter"
version = "1.0.4" version = "1.0.4"
@@ -4007,15 +3834,6 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
version = "0.7.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "wit-bindgen" name = "wit-bindgen"
version = "0.46.0" version = "0.46.0"
@@ -4028,17 +3846,6 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" 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]] [[package]]
name = "yoke" name = "yoke"
version = "0.8.1" version = "0.8.1"

View File

@@ -5,13 +5,11 @@ edition = "2024"
default-run = "api" default-run = "api"
[features] [features]
default = ["sqlite"] default = ["sqlite", "auth-jwt"]
sqlite = ["infra/sqlite", "tower-sessions-sqlx-store/sqlite"] sqlite = ["infra/sqlite"]
postgres = ["infra/postgres", "tower-sessions-sqlx-store/postgres"] postgres = ["infra/postgres"]
auth-axum-login = ["infra/auth-axum-login"]
auth-oidc = ["infra/auth-oidc"] auth-oidc = ["infra/auth-oidc"]
auth-jwt = ["infra/auth-jwt"] auth-jwt = ["infra/auth-jwt"]
auth-full = ["auth-axum-login", "auth-oidc", "auth-jwt"]
[dependencies] [dependencies]
k-core = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-core", features = [ 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", "db-sqlx",
"sqlite", "sqlite",
"http", "http",
"auth",
"sessions-db",
] } ] }
domain = { path = "../domain" } domain = { path = "../domain" }
infra = { path = "../infra", default-features = false, features = ["sqlite"] } infra = { path = "../infra", default-features = false, features = ["sqlite"] }
#Web framework # Web framework
axum = { version = "0.8.8", features = ["macros"] } axum = { version = "0.8.8", features = ["macros"] }
axum-extra = { version = "0.10", features = ["cookie-private", "cookie-key-expansion"] }
tower = "0.5.2" tower = "0.5.2"
tower-http = { version = "0.6.2", features = ["cors", "trace"] } 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 # Async runtime
tokio = { version = "1.48.0", features = ["full"] } 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 = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
# Validation via domain newtypes (Email, Password)
# Error handling # Error handling
thiserror = "2.0.17" thiserror = "2.0.17"
anyhow = "1.0" anyhow = "1.0"
@@ -56,8 +44,6 @@ uuid = { version = "1.19.0", features = ["v4", "serde"] }
# Logging # Logging
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3.22", features = ["env-filter"] }
dotenvy = "0.15.7" dotenvy = "0.15.7"
config = "0.15.19" time = "0.3"
tower-sessions = "0.14.0"

View File

@@ -1,17 +1,15 @@
[package] [package]
name = "api" name = "{{project_name}}"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
default-run = "api" default-run = "{{project_name}}"
[features] [features]
default = ["{{database}}"{% if auth_session %}, "auth-axum-login"{% endif %}{% if auth_oidc %}, "auth-oidc"{% endif %}{% if auth_jwt %}, "auth-jwt"{% endif %}] default = ["{{database}}"{% if auth_oidc %}, "auth-oidc"{% endif %}{% if auth_jwt %}, "auth-jwt"{% endif %}]
sqlite = ["infra/sqlite", "tower-sessions-sqlx-store/sqlite"] sqlite = ["infra/sqlite"]
postgres = ["infra/postgres", "tower-sessions-sqlx-store/postgres"] postgres = ["infra/postgres"]
auth-axum-login = ["infra/auth-axum-login"]
auth-oidc = ["infra/auth-oidc"] auth-oidc = ["infra/auth-oidc"]
auth-jwt = ["infra/auth-jwt"] auth-jwt = ["infra/auth-jwt"]
auth-full = ["auth-axum-login", "auth-oidc", "auth-jwt"]
[dependencies] [dependencies]
k-core = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-core", features = [ 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", "db-sqlx",
"{{database}}", "{{database}}",
"http", "http",
"auth",
"sessions-db",
] } ] }
domain = { path = "../domain" } domain = { path = "../domain" }
infra = { path = "../infra", default-features = false, features = ["{{database}}"] } infra = { path = "../infra", default-features = false, features = ["{{database}}"] }
#Web framework # Web framework
axum = { version = "0.8.8", features = ["macros"] } axum = { version = "0.8.8", features = ["macros"] }
axum-extra = { version = "0.10", features = ["cookie-private", "cookie-key-expansion"] }
tower = "0.5.2" tower = "0.5.2"
tower-http = { version = "0.6.2", features = ["cors", "trace"] } 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 # Async runtime
tokio = { version = "1.48.0", features = ["full"] } 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 = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
# Validation via domain newtypes (Email, Password)
# Error handling # Error handling
thiserror = "2.0.17" thiserror = "2.0.17"
anyhow = "1.0" anyhow = "1.0"
@@ -56,8 +44,6 @@ uuid = { version = "1.19.0", features = ["v4", "serde"] }
# Logging # Logging
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3.22", features = ["env-filter"] }
dotenvy = "0.15.7" dotenvy = "0.15.7"
config = "0.15.19" time = "0.3"
tower-sessions = "0.14.0"

View File

@@ -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<InfraSessionStore>,
user_repo: Arc<dyn UserRepository>,
) -> Result<AuthManagerLayer, ApiError> {
infra::auth::backend::setup_auth_layer(session_layer, user_repo)
.await
.map_err(|e| ApiError::Internal(e.to_string()))
}

View File

@@ -4,52 +4,16 @@
use std::env; use std::env;
use serde::Deserialize; /// Application configuration loaded from environment variables
#[derive(Debug, Clone)]
/// 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)]
pub struct Config { pub struct Config {
pub database_url: String, pub database_url: String,
pub session_secret: String, pub cookie_secret: String,
pub cors_allowed_origins: Vec<String>, pub cors_allowed_origins: Vec<String>,
#[serde(default = "default_port")]
pub port: u16, pub port: u16,
#[serde(default = "default_host")]
pub host: String, pub host: String,
#[serde(default = "default_secure_cookie")]
pub secure_cookie: bool, pub secure_cookie: bool,
#[serde(default = "default_db_max_connections")]
pub db_max_connections: u32, pub db_max_connections: u32,
#[serde(default = "default_db_min_connections")]
pub db_min_connections: u32, pub db_min_connections: u32,
// OIDC configuration // OIDC configuration
@@ -59,57 +23,18 @@ pub struct Config {
pub oidc_redirect_url: Option<String>, pub oidc_redirect_url: Option<String>,
pub oidc_resource_id: Option<String>, pub oidc_resource_id: Option<String>,
// Auth mode configuration
#[serde(default)]
pub auth_mode: AuthMode,
// JWT configuration // JWT configuration
pub jwt_secret: Option<String>, pub jwt_secret: Option<String>,
pub jwt_issuer: Option<String>, pub jwt_issuer: Option<String>,
pub jwt_audience: Option<String>, pub jwt_audience: Option<String>,
#[serde(default = "default_jwt_expiry_hours")]
pub jwt_expiry_hours: u64, pub jwt_expiry_hours: u64,
/// Whether the application is running in production mode /// Whether the application is running in production mode
#[serde(default)]
pub is_production: bool, 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 { impl Config {
pub fn new() -> Result<Self, config::ConfigError> {
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 { pub fn from_env() -> Self {
// Load .env file if it exists, ignore errors if it doesn't
let _ = dotenvy::dotenv(); let _ = dotenvy::dotenv();
let host = env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); let host = env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
@@ -121,8 +46,10 @@ impl Config {
let database_url = let database_url =
env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite:data.db?mode=rwc".to_string()); env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite:data.db?mode=rwc".to_string());
let session_secret = env::var("SESSION_SECRET").unwrap_or_else(|_| { // Cookie secret for PrivateCookieJar (OIDC state encryption).
"k-notes-super-secret-key-must-be-at-least-64-bytes-long!!!!".to_string() // 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") 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_redirect_url = env::var("OIDC_REDIRECT_URL").ok();
let oidc_resource_id = env::var("OIDC_RESOURCE_ID").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_secret = env::var("JWT_SECRET").ok();
let jwt_issuer = env::var("JWT_ISSUER").ok(); let jwt_issuer = env::var("JWT_ISSUER").ok();
let jwt_audience = env::var("JWT_AUDIENCE").ok(); let jwt_audience = env::var("JWT_AUDIENCE").ok();
@@ -178,7 +99,7 @@ impl Config {
host, host,
port, port,
database_url, database_url,
session_secret, cookie_secret,
cors_allowed_origins, cors_allowed_origins,
secure_cookie, secure_cookie,
db_max_connections, db_max_connections,
@@ -188,7 +109,6 @@ impl Config {
oidc_client_secret, oidc_client_secret,
oidc_redirect_url, oidc_redirect_url,
oidc_resource_id, oidc_resource_id,
auth_mode,
jwt_secret, jwt_secret,
jwt_issuer, jwt_issuer,
jwt_audience, jwt_audience,

View File

@@ -10,21 +10,19 @@ use uuid::Uuid;
/// Login request with validated email and password newtypes /// Login request with validated email and password newtypes
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct LoginRequest { pub struct LoginRequest {
/// Email is validated on deserialization /// Email is validated on deserialization
pub email: Email, pub email: Email,
/// Password is validated on deserialization (min 6 chars) /// Password is validated on deserialization (min 8 chars)
pub password: Password, pub password: Password,
} }
/// Register request with validated email and password newtypes /// Register request with validated email and password newtypes
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct RegisterRequest { pub struct RegisterRequest {
/// Email is validated on deserialization /// Email is validated on deserialization
pub email: Email, pub email: Email,
/// Password is validated on deserialization (min 6 chars) /// Password is validated on deserialization (min 8 chars)
pub password: Password, pub password: Password,
} }
@@ -36,6 +34,14 @@ pub struct UserResponse {
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
} }
/// JWT token response
#[derive(Debug, Serialize)]
pub struct TokenResponse {
pub access_token: String,
pub token_type: String,
pub expires_in: u64,
}
/// System configuration response /// System configuration response
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct ConfigResponse { pub struct ConfigResponse {

View File

@@ -14,7 +14,6 @@ use domain::DomainError;
/// API-level errors /// API-level errors
#[derive(Debug, Error)] #[derive(Debug, Error)]
#[allow(dead_code)] // Some variants are reserved for future use
pub enum ApiError { pub enum ApiError {
#[error("{0}")] #[error("{0}")]
Domain(#[from] DomainError), Domain(#[from] DomainError),
@@ -51,11 +50,17 @@ impl IntoResponse for ApiError {
DomainError::ValidationError(_) => StatusCode::BAD_REQUEST, 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(_) => { DomainError::RepositoryError(_) | DomainError::InfrastructureError(_) => {
StatusCode::INTERNAL_SERVER_ERROR StatusCode::INTERNAL_SERVER_ERROR
} }
_ => StatusCode::INTERNAL_SERVER_ERROR,
}; };
( (
@@ -76,7 +81,6 @@ impl IntoResponse for ApiError {
), ),
ApiError::Internal(msg) => { ApiError::Internal(msg) => {
// Log internal errors but don't expose details
tracing::error!("Internal error: {}", msg); tracing::error!("Internal error: {}", msg);
( (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
@@ -108,7 +112,6 @@ impl IntoResponse for ApiError {
} }
} }
#[allow(dead_code)] // Helper constructors for future use
impl ApiError { impl ApiError {
pub fn validation(msg: impl Into<String>) -> Self { pub fn validation(msg: impl Into<String>) -> Self {
Self::Validation(msg.into()) Self::Validation(msg.into())
@@ -120,5 +123,4 @@ impl ApiError {
} }
/// Result type alias for API handlers /// Result type alias for API handlers
#[allow(dead_code)]
pub type ApiResult<T> = Result<T, ApiError>; pub type ApiResult<T> = Result<T, ApiError>;

View File

@@ -1,20 +1,16 @@
//! Auth extractors for API handlers //! 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 axum::{extract::FromRequestParts, http::request::Parts};
use domain::User; use domain::User;
use crate::config::AuthMode;
use crate::error::ApiError; use crate::error::ApiError;
use crate::state::AppState; use crate::state::AppState;
/// Extracted current user from the request. /// Extracted current user from the request.
/// ///
/// This extractor supports multiple authentication methods based on the configured `AuthMode`: /// Validates a JWT Bearer token from the `Authorization` header.
/// - `Session`: Uses axum-login session cookies
/// - `Jwt`: Uses Bearer token in Authorization header
/// - `Both`: Tries JWT first, then falls back to session
pub struct CurrentUser(pub User); pub struct CurrentUser(pub User);
impl FromRequestParts<AppState> for CurrentUser { impl FromRequestParts<AppState> for CurrentUser {
@@ -24,71 +20,47 @@ impl FromRequestParts<AppState> for CurrentUser {
parts: &mut Parts, parts: &mut Parts,
state: &AppState, state: &AppState,
) -> Result<Self, Self::Rejection> { ) -> Result<Self, Self::Rejection> {
let auth_mode = state.config.auth_mode;
// Try JWT first if enabled
#[cfg(feature = "auth-jwt")] #[cfg(feature = "auth-jwt")]
if matches!(auth_mode, AuthMode::Jwt | AuthMode::Both) { {
match try_jwt_auth(parts, state).await { return match try_jwt_auth(parts, state).await {
Ok(Some(user)) => return Ok(CurrentUser(user)), Ok(user) => Ok(CurrentUser(user)),
Ok(None) => { Err(e) => Err(e),
// 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
}
}
} }
// Try session auth if enabled #[cfg(not(feature = "auth-jwt"))]
#[cfg(feature = "auth-axum-login")] {
if matches!(auth_mode, AuthMode::Session | AuthMode::Both) { let _ = (parts, state);
if let Some(user) = try_session_auth(parts).await? { Err(ApiError::Unauthorized(
return Ok(CurrentUser(user)); "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")] #[cfg(feature = "auth-jwt")]
async fn try_jwt_auth(parts: &mut Parts, state: &AppState) -> Result<Option<User>, ApiError> { async fn try_jwt_auth(parts: &mut Parts, state: &AppState) -> Result<User, ApiError> {
use axum::http::header::AUTHORIZATION; use axum::http::header::AUTHORIZATION;
// Get Authorization header let auth_header = parts
let auth_header = match parts.headers.get(AUTHORIZATION) { .headers
Some(header) => header, .get(AUTHORIZATION)
None => return Ok(None), // No header = no JWT auth attempted .ok_or_else(|| ApiError::Unauthorized("Missing Authorization header".to_string()))?;
};
let auth_str = auth_header let auth_str = auth_header
.to_str() .to_str()
.map_err(|_| ApiError::Unauthorized("Invalid Authorization header encoding".to_string()))?; .map_err(|_| ApiError::Unauthorized("Invalid Authorization header encoding".to_string()))?;
// Extract Bearer token
let token = auth_str.strip_prefix("Bearer ").ok_or_else(|| { let token = auth_str.strip_prefix("Bearer ").ok_or_else(|| {
ApiError::Unauthorized("Authorization header must use Bearer scheme".to_string()) ApiError::Unauthorized("Authorization header must use Bearer scheme".to_string())
})?; })?;
// Get JWT validator
let validator = state let validator = state
.jwt_validator .jwt_validator
.as_ref() .as_ref()
.ok_or_else(|| ApiError::Internal("JWT validator not configured".to_string()))?; .ok_or_else(|| ApiError::Internal("JWT validator not configured".to_string()))?;
// Validate token
let claims = validator.validate_token(token).map_err(|e| { let claims = validator.validate_token(token).map_err(|e| {
tracing::debug!("JWT validation failed: {:?}", e); tracing::debug!("JWT validation failed: {:?}", e);
match e { match e {
@@ -102,7 +74,6 @@ async fn try_jwt_auth(parts: &mut Parts, state: &AppState) -> Result<Option<User
} }
})?; })?;
// Fetch user from database by ID (subject contains user ID)
let user_id: uuid::Uuid = claims let user_id: uuid::Uuid = claims
.sub .sub
.parse() .parse()
@@ -114,20 +85,5 @@ async fn try_jwt_auth(parts: &mut Parts, state: &AppState) -> Result<Option<User
.await .await
.map_err(|e| ApiError::Internal(format!("Failed to fetch user: {}", e)))?; .map_err(|e| ApiError::Internal(format!("Failed to fetch user: {}", e)))?;
Ok(Some(user)) Ok(user)
}
/// Try to authenticate using session cookie
#[cfg(feature = "auth-axum-login")]
async fn try_session_auth(parts: &mut Parts) -> Result<Option<User>, ApiError> {
use infra::auth::backend::AuthSession;
// Check if AuthSession extension is present (added by auth middleware)
if let Some(auth_session) = parts.extensions.get::<AuthSession>() {
if let Some(auth_user) = &auth_session.user {
return Ok(Some(auth_user.0.clone()));
}
}
Ok(None)
} }

View File

@@ -1,25 +1,19 @@
//! API Server Entry Point //! 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::net::SocketAddr;
use std::time::Duration as StdDuration; use std::time::Duration as StdDuration;
use axum::Router; use axum::Router;
use domain::UserService; use domain::UserService;
use infra::factory::build_session_store;
use infra::factory::build_user_repository; use infra::factory::build_user_repository;
use infra::run_migrations; use infra::run_migrations;
use infra::session_store::{Expiry, SessionManagerLayer}; use k_core::http::server::{ServerConfig, apply_standard_middleware};
use k_core::http::server::ServerConfig;
use k_core::http::server::apply_standard_middleware;
use k_core::logging; use k_core::logging;
use time::Duration;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tower_sessions::cookie::SameSite;
use tracing::info; use tracing::info;
mod auth;
mod config; mod config;
mod dto; mod dto;
mod error; mod error;
@@ -27,7 +21,7 @@ mod extractors;
mod routes; mod routes;
mod state; mod state;
use crate::config::{AuthMode, Config}; use crate::config::Config;
use crate::state::AppState; use crate::state::AppState;
#[tokio::main] #[tokio::main]
@@ -37,7 +31,6 @@ async fn main() -> anyhow::Result<()> {
let config = Config::from_env(); let config = Config::from_env();
info!("Starting server on {}:{}", config.host, config.port); info!("Starting server on {}:{}", config.host, config.port);
info!("Auth mode: {:?}", config.auth_mode);
// Setup database // Setup database
tracing::info!("Connecting to database: {}", config.database_url); 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?; let db_pool = k_core::db::connect(&db_config).await?;
run_migrations(&db_pool).await?; run_migrations(&db_pool).await?;
let user_repo = build_user_repository(&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?; 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 { let server_config = ServerConfig {
cors_origins: config.cors_allowed_origins.clone(), 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 = Router::new()
let app = build_app(state, session_layer, user_repo, &config).await?; .nest("/api/v1", routes::api_v1_router())
.with_state(state);
let app = apply_standard_middleware(app, &server_config); let app = apply_standard_middleware(app, &server_config);
let addr: SocketAddr = format!("{}:{}", config.host, config.port).parse()?; let addr: SocketAddr = format!("{}:{}", config.host, config.port).parse()?;
let listener = TcpListener::bind(addr).await?; let listener = TcpListener::bind(addr).await?;
tracing::info!("🚀 API server running at http://{}", addr); 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/..."); tracing::info!("📝 API endpoints available at /api/v1/...");
axum::serve(listener, app).await?; axum::serve(listener, app).await?;
Ok(()) 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<infra::session_store::InfraSessionStore>,
user_repo: std::sync::Arc<dyn domain::UserRepository>,
config: &Config,
) -> anyhow::Result<Router> {
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");
}
}

View File

@@ -1,10 +1,8 @@
//! Authentication routes //! Authentication routes
//! //!
//! Provides login, register, logout, and token endpoints. //! Provides login, register, logout, token, and OIDC endpoints.
//! Supports both session-based and JWT-based authentication. //! All authentication is JWT-based. OIDC state is stored in an encrypted cookie.
#[cfg(feature = "auth-oidc")]
use axum::response::Response;
use axum::{ use axum::{
Router, Router,
extract::{Json, State}, extract::{Json, State},
@@ -12,36 +10,13 @@ use axum::{
response::IntoResponse, response::IntoResponse,
routing::{get, post}, 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::{ use crate::{
dto::{LoginRequest, RegisterRequest, UserResponse}, dto::{LoginRequest, RegisterRequest, TokenResponse, UserResponse},
error::ApiError, error::ApiError,
extractors::CurrentUser, extractors::CurrentUser,
state::AppState, 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<AppState> { pub fn router() -> Router<AppState> {
let r = Router::new() let r = Router::new()
@@ -50,7 +25,6 @@ pub fn router() -> Router<AppState> {
.route("/logout", post(logout)) .route("/logout", post(logout))
.route("/me", get(me)); .route("/me", get(me));
// Add token endpoint for getting JWT from session
#[cfg(feature = "auth-jwt")] #[cfg(feature = "auth-jwt")]
let r = r.route("/token", post(get_token)); let r = r.route("/token", post(get_token));
@@ -62,171 +36,68 @@ pub fn router() -> Router<AppState> {
r r
} }
/// Login endpoint /// Login with email + password → JWT token
///
/// 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")]
async fn login( async fn login(
State(state): State<AppState>, State(state): State<AppState>,
mut auth_session: crate::auth::AuthSession,
Json(payload): Json<LoginRequest>, Json(payload): Json<LoginRequest>,
) -> Result<impl IntoResponse, ApiError> { ) -> Result<impl IntoResponse, ApiError> {
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<AppState>,
Json(_payload): Json<LoginRequest>,
) -> Result<(StatusCode, Json<LoginResponse>), 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<AppState>,
mut auth_session: crate::auth::AuthSession,
Json(payload): Json<RegisterRequest>,
) -> Result<impl IntoResponse, ApiError> {
// 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 let user = state
.user_service .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<AppState>,
Json(payload): Json<RegisterRequest>,
) -> Result<impl IntoResponse, ApiError> {
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?; .await?;
let auth_mode = state.config.auth_mode; let token = create_jwt(&user, &state)?;
// 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,
})),
));
}
Ok(( Ok((
StatusCode::CREATED, StatusCode::CREATED,
Json(LoginResponse::User(UserResponse { Json(TokenResponse {
id: user.id, access_token: token,
email: user.email.into_inner(), token_type: "Bearer".to_string(),
created_at: user.created_at, expires_in: state.config.jwt_expiry_hours * 3600,
})), }),
)) ))
} }
/// Fallback register when auth-axum-login is not enabled /// Logout — JWT is stateless; instruct the client to drop the token
#[cfg(not(feature = "auth-axum-login"))]
async fn register(
State(_state): State<AppState>,
Json(_payload): Json<RegisterRequest>,
) -> Result<(StatusCode, Json<LoginResponse>), 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"))]
async fn logout() -> impl IntoResponse { async fn logout() -> impl IntoResponse {
// JWT tokens can't be "logged out" server-side without a blocklist
// Just return OK
StatusCode::OK StatusCode::OK
} }
/// Get current user info /// Get current user info from JWT
async fn me(CurrentUser(user): CurrentUser) -> Result<impl IntoResponse, ApiError> { async fn me(CurrentUser(user): CurrentUser) -> Result<impl IntoResponse, ApiError> {
Ok(Json(UserResponse { Ok(Json(UserResponse {
id: user.id, id: user.id,
@@ -235,15 +106,13 @@ async fn me(CurrentUser(user): CurrentUser) -> Result<impl IntoResponse, ApiErro
})) }))
} }
/// Get a JWT token for the current session user /// Issue a new JWT for the currently authenticated user (OIDC→JWT exchange or token refresh)
///
/// This allows session-authenticated users to obtain a JWT for API access.
#[cfg(feature = "auth-jwt")] #[cfg(feature = "auth-jwt")]
async fn get_token( async fn get_token(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser(user): CurrentUser, CurrentUser(user): CurrentUser,
) -> Result<impl IntoResponse, ApiError> { ) -> Result<impl IntoResponse, ApiError> {
let token = create_jwt_for_user(&user, &state)?; let token = create_jwt(&user, &state)?;
Ok(Json(TokenResponse { Ok(Json(TokenResponse {
access_token: token, 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")] #[cfg(feature = "auth-jwt")]
fn create_jwt_for_user(user: &domain::User, state: &AppState) -> Result<String, ApiError> { fn create_jwt(user: &domain::User, state: &AppState) -> Result<String, ApiError> {
let validator = state let validator = state
.jwt_validator .jwt_validator
.as_ref() .as_ref()
@@ -265,37 +134,54 @@ fn create_jwt_for_user(user: &domain::User, state: &AppState) -> Result<String,
.map_err(|e| ApiError::Internal(format!("Failed to create token: {}", e))) .map_err(|e| ApiError::Internal(format!("Failed to create token: {}", e)))
} }
#[cfg(not(feature = "auth-jwt"))]
fn create_jwt(_user: &domain::User, _state: &AppState) -> Result<String, ApiError> {
Err(ApiError::Internal("JWT feature not enabled".to_string()))
}
// ============================================================================ // ============================================================================
// OIDC Routes // OIDC Routes
// ============================================================================ // ============================================================================
#[cfg(feature = "auth-oidc")] #[cfg(feature = "auth-oidc")]
async fn oidc_login(State(state): State<AppState>, session: Session) -> Result<Response, ApiError> { #[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<AppState>,
jar: axum_extra::extract::PrivateCookieJar,
) -> Result<impl IntoResponse, ApiError> {
use axum::http::header; use axum::http::header;
use axum::response::Response;
use axum_extra::extract::cookie::{Cookie, SameSite};
let service = state let service = state
.oidc_service .oidc_service
.as_ref() .as_ref()
.ok_or(ApiError::Internal("OIDC not configured".into()))?; .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 let state_json = serde_json::to_string(&oidc_state)
.insert("oidc_csrf", &auth_data.csrf_token) .map_err(|e| ApiError::Internal(format!("Failed to serialize OIDC state: {}", e)))?;
.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 response = axum::response::Redirect::to(auth_data.url.as_str()).into_response(); let cookie = Cookie::build(("oidc_state", state_json))
let (mut parts, body) = response.into_parts(); .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( parts.headers.insert(
header::CACHE_CONTROL, header::CACHE_CONTROL,
"no-cache, no-store, must-revalidate".parse().unwrap(), "no-cache, no-store, must-revalidate".parse().unwrap(),
@@ -305,54 +191,42 @@ async fn oidc_login(State(state): State<AppState>, session: Session) -> Result<R
.insert(header::PRAGMA, "no-cache".parse().unwrap()); .insert(header::PRAGMA, "no-cache".parse().unwrap());
parts.headers.insert(header::EXPIRES, "0".parse().unwrap()); parts.headers.insert(header::EXPIRES, "0".parse().unwrap());
Ok(Response::from_parts(parts, body)) Ok((updated_jar, Response::from_parts(parts, body)))
} }
/// Handle OIDC callback: verify state cookie, complete exchange, issue JWT, clear cookie
#[cfg(feature = "auth-oidc")] #[cfg(feature = "auth-oidc")]
#[derive(serde::Deserialize)]
struct CallbackParams {
code: String,
state: String,
}
#[cfg(all(feature = "auth-oidc", feature = "auth-axum-login"))]
async fn oidc_callback( async fn oidc_callback(
State(state): State<AppState>, State(state): State<AppState>,
session: Session, jar: axum_extra::extract::PrivateCookieJar,
mut auth_session: crate::auth::AuthSession,
axum::extract::Query(params): axum::extract::Query<CallbackParams>, axum::extract::Query(params): axum::extract::Query<CallbackParams>,
) -> Result<impl IntoResponse, ApiError> { ) -> Result<impl IntoResponse, ApiError> {
use infra::auth::oidc::OidcState;
let service = state let service = state
.oidc_service .oidc_service
.as_ref() .as_ref()
.ok_or(ApiError::Internal("OIDC not configured".into()))?; .ok_or(ApiError::Internal("OIDC not configured".into()))?;
let stored_csrf: domain::CsrfToken = session // Read and decrypt OIDC state from cookie
.get("oidc_csrf") let cookie = jar
.await .get("oidc_state")
.map_err(|_| ApiError::Internal("Session error".into()))? .ok_or(ApiError::Validation("Missing OIDC state cookie".into()))?;
.ok_or(ApiError::Validation("Missing CSRF token".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())); return Err(ApiError::Validation("Invalid CSRF token".into()));
} }
let stored_pkce: domain::PkceVerifier = session // Complete OIDC exchange
.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 let oidc_user = service
.resolve_callback( .resolve_callback(
domain::AuthorizationCode::new(params.code), domain::AuthorizationCode::new(params.code),
stored_nonce, oidc_state.nonce,
stored_pkce, oidc_state.pkce_verifier,
) )
.await .await
.map_err(|e| ApiError::Internal(e.to_string()))?; .map_err(|e| ApiError::Internal(e.to_string()))?;
@@ -363,129 +237,17 @@ async fn oidc_callback(
.await .await
.map_err(|e| ApiError::Internal(e.to_string()))?; .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 let token = create_jwt(&user, &state)?;
if matches!(auth_mode, AuthMode::Session | AuthMode::Both) {
auth_session
.login(&crate::auth::AuthUser(user.clone()))
.await
.map_err(|_| ApiError::Internal("Login failed".into()))?;
}
// Clean up OIDC state Ok((
let _: Option<String> = session cleared_jar,
.remove("oidc_csrf") Json(TokenResponse {
.await
.map_err(|_| ApiError::Internal("Session error".into()))?;
let _: Option<String> = session
.remove("oidc_pkce")
.await
.map_err(|_| ApiError::Internal("Session error".into()))?;
let _: Option<String> = 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 {
access_token: token, access_token: token,
token_type: "Bearer".to_string(), token_type: "Bearer".to_string(),
expires_in: state.config.jwt_expiry_hours * 3600, 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<AppState>,
session: Session,
axum::extract::Query(params): axum::extract::Query<CallbackParams>,
) -> Result<impl IntoResponse, ApiError> {
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<String> = session
.remove("oidc_csrf")
.await
.map_err(|_| ApiError::Internal("Session error".into()))?;
let _: Option<String> = session
.remove("oidc_pkce")
.await
.map_err(|_| ApiError::Internal("Session error".into()))?;
let _: Option<String> = 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(),
))
}
} }

View File

@@ -3,18 +3,20 @@
//! Holds shared state for the application. //! Holds shared state for the application.
use axum::extract::FromRef; use axum::extract::FromRef;
use axum_extra::extract::cookie::Key;
#[cfg(feature = "auth-jwt")] #[cfg(feature = "auth-jwt")]
use infra::auth::jwt::{JwtConfig, JwtValidator}; use infra::auth::jwt::{JwtConfig, JwtValidator};
#[cfg(feature = "auth-oidc")] #[cfg(feature = "auth-oidc")]
use infra::auth::oidc::OidcService; use infra::auth::oidc::OidcService;
use std::sync::Arc; use std::sync::Arc;
use crate::config::{AuthMode, Config}; use crate::config::Config;
use domain::UserService; use domain::UserService;
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
pub user_service: Arc<UserService>, pub user_service: Arc<UserService>,
pub cookie_key: Key,
#[cfg(feature = "auth-oidc")] #[cfg(feature = "auth-oidc")]
pub oidc_service: Option<Arc<OidcService>>, pub oidc_service: Option<Arc<OidcService>>,
#[cfg(feature = "auth-jwt")] #[cfg(feature = "auth-jwt")]
@@ -24,6 +26,8 @@ pub struct AppState {
impl AppState { impl AppState {
pub async fn new(user_service: UserService, config: Config) -> anyhow::Result<Self> { pub async fn new(user_service: UserService, config: Config) -> anyhow::Result<Self> {
let cookie_key = Key::derive_from(config.cookie_secret.as_bytes());
#[cfg(feature = "auth-oidc")] #[cfg(feature = "auth-oidc")]
let oidc_service = if let (Some(issuer), Some(id), secret, Some(redirect), resource_id) = ( let oidc_service = if let (Some(issuer), Some(id), secret, Some(redirect), resource_id) = (
&config.oidc_issuer, &config.oidc_issuer,
@@ -34,7 +38,6 @@ impl AppState {
) { ) {
tracing::info!("Initializing OIDC service with issuer: {}", issuer); tracing::info!("Initializing OIDC service with issuer: {}", issuer);
// Construct newtypes from config strings
let issuer_url = domain::IssuerUrl::new(issuer) let issuer_url = domain::IssuerUrl::new(issuer)
.map_err(|e| anyhow::anyhow!("Invalid OIDC issuer URL: {}", e))?; .map_err(|e| anyhow::anyhow!("Invalid OIDC issuer URL: {}", e))?;
let client_id = domain::ClientId::new(id) let client_id = domain::ClientId::new(id)
@@ -57,25 +60,15 @@ impl AppState {
}; };
#[cfg(feature = "auth-jwt")] #[cfg(feature = "auth-jwt")]
let jwt_validator = if matches!(config.auth_mode, AuthMode::Jwt | AuthMode::Both) { let jwt_validator = {
// Use provided secret or fall back to a development secret let secret = match &config.jwt_secret {
let secret = if let Some(ref s) = config.jwt_secret { Some(s) if !s.is_empty() => s.clone(),
if s.is_empty() { None } else { Some(s.clone()) } _ => {
} else {
None
};
let secret = match secret {
Some(s) => s,
None => {
if config.is_production { if config.is_production {
anyhow::bail!( anyhow::bail!("JWT_SECRET is required in production");
"JWT_SECRET is required when AUTH_MODE is 'jwt' or 'both' in production"
);
} }
// Use a development-only default secret
tracing::warn!( 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() "k-template-dev-secret-not-for-production-use-only".to_string()
} }
@@ -90,12 +83,11 @@ impl AppState {
config.is_production, config.is_production,
)?; )?;
Some(Arc::new(JwtValidator::new(jwt_config))) Some(Arc::new(JwtValidator::new(jwt_config)))
} else {
None
}; };
Ok(Self { Ok(Self {
user_service: Arc::new(user_service), user_service: Arc::new(user_service),
cookie_key,
#[cfg(feature = "auth-oidc")] #[cfg(feature = "auth-oidc")]
oidc_service, oidc_service,
#[cfg(feature = "auth-jwt")] #[cfg(feature = "auth-jwt")]
@@ -116,3 +108,9 @@ impl FromRef<AppState> for Arc<Config> {
input.config.clone() input.config.clone()
} }
} }
impl FromRef<AppState> for Key {
fn from_ref(input: &AppState) -> Self {
input.cookie_key.clone()
}
}

View File

@@ -24,11 +24,6 @@ prompt = "Database type"
choices = ["sqlite", "postgres"] choices = ["sqlite", "postgres"]
default = "sqlite" default = "sqlite"
[placeholders.auth_session]
type = "bool"
prompt = "Enable session-based authentication (cookies)?"
default = true
[placeholders.auth_jwt] [placeholders.auth_jwt]
type = "bool" type = "bool"
prompt = "Enable JWT authentication (Bearer tokens)?" prompt = "Enable JWT authentication (Bearer tokens)?"

View File

@@ -4,17 +4,13 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
anyhow = "1.0.100"
async-trait = "0.1.89" async-trait = "0.1.89"
chrono = { version = "0.4.42", features = ["serde"] } chrono = { version = "0.4.42", features = ["serde"] }
email_address = "0.2" email_address = "0.2"
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.146"
thiserror = "2.0.17" thiserror = "2.0.17"
tracing = "0.1"
url = { version = "2.5", features = ["serde"] } url = { version = "2.5", features = ["serde"] }
uuid = { version = "1.19.0", features = ["v4", "serde"] } uuid = { version = "1.19.0", features = ["v4", "serde"] }
futures-core = "0.3"
[dev-dependencies] [dev-dependencies]
tokio = { version = "1", features = ["rt", "macros"] } tokio = { version = "1", features = ["rt", "macros"] }

View File

@@ -57,8 +57,4 @@ impl User {
} }
} }
/// Helper to get email as string
pub fn email_str(&self) -> &str {
self.email.as_ref()
}
} }

View File

@@ -9,6 +9,7 @@ use uuid::Uuid;
/// Domain-level errors for K-Notes operations /// Domain-level errors for K-Notes operations
#[derive(Debug, Error)] #[derive(Debug, Error)]
#[non_exhaustive]
pub enum DomainError { pub enum DomainError {
/// The requested user was not found /// The requested user was not found
#[error("User not found: {0}")] #[error("User not found: {0}")]
@@ -22,9 +23,13 @@ pub enum DomainError {
#[error("Validation error: {0}")] #[error("Validation error: {0}")]
ValidationError(String), ValidationError(String),
/// User is not authorized to perform this action /// User is not authenticated (maps to HTTP 401)
#[error("Unauthorized: {0}")] #[error("Unauthenticated: {0}")]
Unauthorized(String), Unauthenticated(String),
/// User is not allowed to perform this action (maps to HTTP 403)
#[error("Forbidden: {0}")]
Forbidden(String),
/// A repository/infrastructure error occurred /// A repository/infrastructure error occurred
#[error("Repository error: {0}")] #[error("Repository error: {0}")]
@@ -41,9 +46,14 @@ impl DomainError {
Self::ValidationError(message.into()) Self::ValidationError(message.into())
} }
/// Create an unauthorized error /// Create an unauthenticated error (not logged in → 401)
pub fn unauthorized(message: impl Into<String>) -> Self { pub fn unauthenticated(message: impl Into<String>) -> Self {
Self::Unauthorized(message.into()) Self::Unauthenticated(message.into())
}
/// Create a forbidden error (not allowed → 403)
pub fn forbidden(message: impl Into<String>) -> Self {
Self::Forbidden(message.into())
} }
/// Check if this error indicates a "not found" condition /// Check if this error indicates a "not found" condition

View File

@@ -1,5 +1,5 @@
//! Reference Repository ports (traits) //! Reference Repository ports (traits)
//! //!
//! These traits define the interface for data persistence. //! These traits define the interface for data persistence.
use async_trait::async_trait; use async_trait::async_trait;

View File

@@ -17,6 +17,7 @@ pub type UserId = Uuid;
/// Errors that occur when parsing/validating value objects /// Errors that occur when parsing/validating value objects
#[derive(Debug, Error, Clone, PartialEq, Eq)] #[derive(Debug, Error, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ValidationError { pub enum ValidationError {
#[error("Invalid email format: {0}")] #[error("Invalid email format: {0}")]
InvalidEmail(String), InvalidEmail(String),
@@ -109,8 +110,8 @@ impl<'de> Deserialize<'de> for Email {
#[derive(Clone, PartialEq, Eq)] #[derive(Clone, PartialEq, Eq)]
pub struct Password(String); pub struct Password(String);
/// Minimum password length /// Minimum password length (NIST recommendation)
pub const MIN_PASSWORD_LENGTH: usize = 6; pub const MIN_PASSWORD_LENGTH: usize = 8;
impl Password { impl Password {
pub fn new(value: impl Into<String>) -> Result<Self, ValidationError> { pub fn new(value: impl Into<String>) -> Result<Self, ValidationError> {
@@ -497,82 +498,6 @@ pub struct AuthorizationUrlData {
// Configuration Newtypes // 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<String>) -> Result<Self, ValidationError> {
let value = value.into();
if value.trim().is_empty() {
return Err(ValidationError::Empty("database_url".to_string()));
}
Ok(Self(value))
}
}
impl AsRef<str> 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<String> for DatabaseUrl {
type Error = ValidationError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl From<DatabaseUrl> 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<String>) -> Result<Self, ValidationError> {
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<String>) -> Self {
Self(value.into())
}
}
impl AsRef<str> 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 /// JWT signing secret with minimum length requirement
pub const MIN_JWT_SECRET_LENGTH: usize = 32; pub const MIN_JWT_SECRET_LENGTH: usize = 32;
@@ -655,12 +580,12 @@ mod tests {
#[test] #[test]
fn test_valid_password() { fn test_valid_password() {
assert!(Password::new("secret123").is_ok()); 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] #[test]
fn test_password_too_short() { 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()); assert!(Password::new("").is_err());
} }
@@ -705,15 +630,6 @@ mod tests {
mod secret_tests { mod secret_tests {
use super::*; 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] #[test]
fn test_jwt_secret_production_check() { fn test_jwt_secret_production_check() {
let short = "short"; let short = "short";
@@ -729,10 +645,7 @@ mod tests {
#[test] #[test]
fn test_secrets_hide_in_debug() { fn test_secrets_hide_in_debug() {
let session = SessionSecret::new_unchecked("secret");
let jwt = JwtSecret::new_unchecked("secret"); let jwt = JwtSecret::new_unchecked("secret");
assert!(!format!("{:?}", session).contains("secret"));
assert!(!format!("{:?}", jwt).contains("secret")); assert!(!format!("{:?}", jwt).contains("secret"));
} }
} }

View File

@@ -5,28 +5,16 @@ edition = "2024"
[features] [features]
default = ["sqlite"] default = ["sqlite"]
sqlite = [ sqlite = ["sqlx/sqlite", "k-core/sqlite"]
"sqlx/sqlite", postgres = ["sqlx/postgres", "k-core/postgres"]
"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",
]
broker-nats = ["dep:futures-util", "k-core/broker-nats"] broker-nats = ["dep:futures-util", "k-core/broker-nats"]
auth-axum-login = ["dep:axum-login", "dep:password-auth"] auth-oidc = ["dep:openidconnect", "dep:url", "dep:axum-extra"]
auth-oidc = ["dep:openidconnect", "dep:url"]
auth-jwt = ["dep:jsonwebtoken"] auth-jwt = ["dep:jsonwebtoken"]
[dependencies] [dependencies]
k-core = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-core", features = [ k-core = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-core", features = [
"logging", "logging",
"db-sqlx", "db-sqlx",
"sessions-db",
] } ] }
domain = { path = "../domain" } domain = { path = "../domain" }
@@ -38,19 +26,17 @@ anyhow = "1.0"
tokio = { version = "1.48.0", features = ["full"] } tokio = { version = "1.48.0", features = ["full"] }
tracing = "0.1" tracing = "0.1"
uuid = { version = "1.19.0", features = ["v4", "serde"] } uuid = { version = "1.19.0", features = ["v4", "serde"] }
tower-sessions-sqlx-store = { version = "0.15.0", optional = true }
serde_json = "1.0" serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
futures-core = "0.3"
password-auth = "1.0"
# Optional dependencies
async-nats = { version = "0.45", optional = true } async-nats = { version = "0.45", optional = true }
futures-util = { version = "0.3", 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 } openidconnect = { version = "4.0.1", optional = true }
url = { version = "2.5.8", 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 = [ jsonwebtoken = { version = "10.2.0", features = [
"sha2", "sha2",
"p256", "p256",

View File

@@ -4,29 +4,17 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[features] [features]
default = ["{{database}}"{% if auth_session %}, "auth-axum-login"{% endif %}{% if auth_oidc %}, "auth-oidc"{% endif %}{% if auth_jwt %}, "auth-jwt"{% endif %}] default = ["{{database}}"{% if auth_oidc %}, "auth-oidc"{% endif %}{% if auth_jwt %}, "auth-jwt"{% endif %}]
sqlite = [ sqlite = ["sqlx/sqlite", "k-core/sqlite"]
"sqlx/sqlite", postgres = ["sqlx/postgres", "k-core/postgres"]
"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",
]
broker-nats = ["dep:futures-util", "k-core/broker-nats"] broker-nats = ["dep:futures-util", "k-core/broker-nats"]
auth-axum-login = ["dep:axum-login", "dep:password-auth"] auth-oidc = ["dep:openidconnect", "dep:url", "dep:axum-extra"]
auth-oidc = ["dep:openidconnect", "dep:url"]
auth-jwt = ["dep:jsonwebtoken"] auth-jwt = ["dep:jsonwebtoken"]
[dependencies] [dependencies]
k-core = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-core", features = [ k-core = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-core", features = [
"logging", "logging",
"db-sqlx", "db-sqlx",
"sessions-db",
] } ] }
domain = { path = "../domain" } domain = { path = "../domain" }
@@ -38,18 +26,15 @@ anyhow = "1.0"
tokio = { version = "1.48.0", features = ["full"] } tokio = { version = "1.48.0", features = ["full"] }
tracing = "0.1" tracing = "0.1"
uuid = { version = "1.19.0", features = ["v4", "serde"] } uuid = { version = "1.19.0", features = ["v4", "serde"] }
tower-sessions-sqlx-store = { version = "0.15.0", optional = true }
serde_json = "1.0" serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
futures-core = "0.3"
password-auth = "1.0"
# Optional dependencies
async-nats = { version = "0.45", optional = true } async-nats = { version = "0.45", optional = true }
futures-util = { version = "0.3", 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 } openidconnect = { version = "4.0.1", optional = true }
url = { version = "2.5.8", 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 } jsonwebtoken = { version = "9.3", optional = true }
# reqwest = { version = "0.13.1", features = ["blocking", "json"], optional = true }

View File

@@ -2,122 +2,14 @@
//! //!
//! This module contains the concrete implementation of authentication mechanisms. //! This module contains the concrete implementation of authentication mechanisms.
#[cfg(feature = "auth-axum-login")] /// Hash a password using the password-auth crate
pub mod backend { pub fn hash_password(password: &str) -> String {
use std::sync::Arc; password_auth::generate_hash(password)
}
use axum_login::{AuthnBackend, UserId}; /// Verify a password against a stored hash
use password_auth::verify_password; pub fn verify_password(password: &str, hash: &str) -> bool {
use serde::{Deserialize, Serialize}; password_auth::verify_password(password, hash).is_ok()
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<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: 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<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)
}
} }
#[cfg(feature = "auth-oidc")] #[cfg(feature = "auth-oidc")]

View File

@@ -15,6 +15,7 @@ use openidconnect::{
}, },
reqwest, reqwest,
}; };
use serde::{Deserialize, Serialize};
pub type OidcClient = Client< pub type OidcClient = Client<
EmptyAdditionalClaims, EmptyAdditionalClaims,
@@ -36,9 +37,18 @@ pub type OidcClient = Client<
EndpointMaybeSet, // HasUserInfoUrl (Discovered, might be missing) 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)] #[derive(Clone)]
pub struct OidcService { pub struct OidcService {
client: OidcClient, client: OidcClient,
http_client: reqwest::Client,
resource_id: Option<ResourceId>, resource_id: Option<ResourceId>,
} }
@@ -61,11 +71,7 @@ impl OidcService {
tracing::debug!("🔵 OIDC Setup: Redirect = '{}'", redirect_url); tracing::debug!("🔵 OIDC Setup: Redirect = '{}'", redirect_url);
tracing::debug!( tracing::debug!(
"🔵 OIDC Setup: Secret = {:?}", "🔵 OIDC Setup: Secret = {:?}",
if client_secret.is_some() { if client_secret.is_some() { "SET" } else { "NONE" }
"SET"
} else {
"NONE"
}
); );
let http_client = reqwest::ClientBuilder::new() let http_client = reqwest::ClientBuilder::new()
@@ -78,13 +84,13 @@ impl OidcService {
) )
.await?; .await?;
// Convert to openidconnect types
let oidc_client_id = openidconnect::ClientId::new(client_id.as_ref().to_string()); let oidc_client_id = openidconnect::ClientId::new(client_id.as_ref().to_string());
let oidc_client_secret = client_secret let oidc_client_secret = client_secret
.as_ref() .as_ref()
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.map(|s| openidconnect::ClientSecret::new(s.as_ref().to_string())); .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( let client = CoreClient::from_provider_metadata(
provider_metadata, provider_metadata,
@@ -95,14 +101,16 @@ impl OidcService {
Ok(Self { Ok(Self {
client, client,
http_client,
resource_id, 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 /// Returns `(AuthorizationUrlData, OidcState)` where `OidcState` should be
pub fn get_authorization_url(&self) -> AuthorizationUrlData { /// 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 (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
let (auth_url, csrf_token, nonce) = self let (auth_url, csrf_token, nonce) = self
@@ -117,12 +125,20 @@ impl OidcService {
.set_pkce_challenge(pkce_challenge) .set_pkce_challenge(pkce_challenge)
.url(); .url();
AuthorizationUrlData { let oidc_state = OidcState {
url: auth_url.into(),
csrf_token: CsrfToken::new(csrf_token.secret().to_string()), csrf_token: CsrfToken::new(csrf_token.secret().to_string()),
nonce: OidcNonce::new(nonce.secret().to_string()), nonce: OidcNonce::new(nonce.secret().to_string()),
pkce_verifier: PkceVerifier::new(pkce_verifier.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 /// Resolve the OIDC callback with type-safe parameters
@@ -132,10 +148,6 @@ impl OidcService {
nonce: OidcNonce, nonce: OidcNonce,
pkce_verifier: PkceVerifier, pkce_verifier: PkceVerifier,
) -> anyhow::Result<OidcUser> { ) -> anyhow::Result<OidcUser> {
let http_client = reqwest::ClientBuilder::new()
.redirect(reqwest::redirect::Policy::none())
.build()?;
let oidc_pkce_verifier = let oidc_pkce_verifier =
openidconnect::PkceCodeVerifier::new(pkce_verifier.as_ref().to_string()); openidconnect::PkceCodeVerifier::new(pkce_verifier.as_ref().to_string());
let oidc_nonce = openidconnect::Nonce::new(nonce.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(), code.as_ref().to_string(),
))? ))?
.set_pkce_verifier(oidc_pkce_verifier) .set_pkce_verifier(oidc_pkce_verifier)
.request_async(&http_client) .request_async(&self.http_client)
.await?; .await?;
let id_token = token_response let id_token = token_response
@@ -178,19 +190,17 @@ impl OidcService {
let email = if let Some(email) = claims.email() { let email = if let Some(email) = claims.email() {
Some(email.as_str().to_string()) Some(email.as_str().to_string())
} else { } else {
// Fallback: Call UserInfo Endpoint using the Access Token
tracing::debug!("🔵 Email missing in ID Token, fetching UserInfo..."); tracing::debug!("🔵 Email missing in ID Token, fetching UserInfo...");
let user_info: UserInfoClaims<EmptyAdditionalClaims, CoreGenderClaim> = self let user_info: UserInfoClaims<EmptyAdditionalClaims, CoreGenderClaim> = self
.client .client
.user_info(token_response.access_token().clone(), None)? .user_info(token_response.access_token().clone(), None)?
.request_async(&http_client) .request_async(&self.http_client)
.await?; .await?;
user_info.email().map(|e| e.as_str().to_string()) 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 = let email =
email.ok_or_else(|| anyhow!("User has no verified email address in ZITADEL"))?; email.ok_or_else(|| anyhow!("User has no verified email address in ZITADEL"))?;

View File

@@ -5,8 +5,6 @@ use crate::SqliteUserRepository;
use crate::db::DatabasePool; use crate::db::DatabasePool;
use domain::UserRepository; use domain::UserRepository;
use k_core::session::store::InfraSessionStore;
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum FactoryError { pub enum FactoryError {
#[error("Database error: {0}")] #[error("Database error: {0}")]
@@ -33,18 +31,3 @@ pub async fn build_user_repository(pool: &DatabasePool) -> FactoryResult<Arc<dyn
)), )),
} }
} }
pub async fn build_session_store(
pool: &DatabasePool,
) -> FactoryResult<crate::session_store::InfraSessionStore> {
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()))
}
})
}

View File

@@ -5,9 +5,7 @@
//! //!
//! ## Adapters //! ## Adapters
//! //!
//! - [`SqliteNoteRepository`] - SQLite adapter for notes with FTS5 search
//! - [`SqliteUserRepository`] - SQLite adapter for users (OIDC-ready) //! - [`SqliteUserRepository`] - SQLite adapter for users (OIDC-ready)
//! - [`SqliteTagRepository`] - SQLite adapter for tags
//! //!
//! ## Database //! ## Database
//! //!
@@ -17,7 +15,6 @@
pub mod auth; pub mod auth;
pub mod db; pub mod db;
pub mod factory; pub mod factory;
pub mod session_store;
mod user_repository; mod user_repository;
// Re-export for convenience // Re-export for convenience

View File

@@ -1,2 +0,0 @@
pub use k_core::session::store::InfraSessionStore;
pub use tower_sessions::{Expiry, SessionManagerLayer};

View File

@@ -1,27 +1,13 @@
//! SQLite implementation of UserRepository //! SQLite and PostgreSQL implementations of UserRepository
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use sqlx::{FromRow, SqlitePool}; use sqlx::FromRow;
use uuid::Uuid; use uuid::Uuid;
use domain::{DomainError, DomainResult, Email, User, UserRepository}; use domain::{DomainError, DomainResult, Email, User, UserRepository};
/// SQLite adapter for UserRepository /// Row type for database query results (shared between SQLite and PostgreSQL)
#[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
#[derive(Debug, FromRow)] #[derive(Debug, FromRow)]
struct UserRow { struct UserRow {
id: String, id: String,
@@ -46,7 +32,6 @@ impl TryFrom<UserRow> for User {
}) })
.map_err(|e| DomainError::RepositoryError(format!("Invalid datetime: {}", e)))?; .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) let email = Email::try_from(row.email)
.map_err(|e| DomainError::RepositoryError(format!("Invalid email in DB: {}", e)))?; .map_err(|e| DomainError::RepositoryError(format!("Invalid email in DB: {}", e)))?;
@@ -60,6 +45,20 @@ impl TryFrom<UserRow> 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")] #[cfg(feature = "sqlite")]
#[async_trait] #[async_trait]
impl UserRepository for SqliteUserRepository { impl UserRepository for SqliteUserRepository {
@@ -116,12 +115,20 @@ impl UserRepository for SqliteUserRepository {
) )
.bind(&id) .bind(&id)
.bind(&user.subject) .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(&user.password_hash)
.bind(&created_at) .bind(&created_at)
.execute(&self.pool) .execute(&self.pool)
.await .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(()) Ok(())
} }
@@ -144,7 +151,7 @@ mod tests {
use crate::db::run_migrations; use crate::db::run_migrations;
use k_core::db::{DatabaseConfig, DatabasePool, connect}; 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 config = DatabaseConfig::default();
let db_pool = connect(&config).await.expect("Failed to create pool"); let db_pool = connect(&config).await.expect("Failed to create pool");
@@ -168,7 +175,7 @@ mod tests {
assert!(found.is_some()); assert!(found.is_some());
let found = found.unwrap(); let found = found.unwrap();
assert_eq!(found.subject, "oidc|123"); 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()); assert!(found.password_hash.is_none());
} }
@@ -184,7 +191,7 @@ mod tests {
let found = repo.find_by_id(user.id).await.unwrap(); let found = repo.find_by_id(user.id).await.unwrap();
assert!(found.is_some()); assert!(found.is_some());
let found = found.unwrap(); 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())); assert_eq!(found.password_hash, Some("hashed_pw".to_string()));
} }
@@ -292,7 +299,14 @@ impl UserRepository for PostgresUserRepository {
.bind(&created_at) .bind(&created_at)
.execute(&self.pool) .execute(&self.pool)
.await .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(()) Ok(())
} }