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:
435
Cargo.lock
generated
435
Cargo.lock
generated
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
|
||||||
|
|||||||
@@ -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"
|
|
||||||
|
|||||||
@@ -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()))
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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) => {
|
#[cfg(not(feature = "auth-jwt"))]
|
||||||
// JWT was present but invalid
|
{
|
||||||
tracing::debug!("JWT auth failed: {}", e);
|
let _ = (parts, state);
|
||||||
if auth_mode == AuthMode::Jwt {
|
Err(ApiError::Unauthorized(
|
||||||
return Err(e);
|
"No authentication backend configured".to_string(),
|
||||||
}
|
))
|
||||||
// In Both mode, continue to try session
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try session auth if enabled
|
/// Authenticate using JWT Bearer token
|
||||||
#[cfg(feature = "auth-axum-login")]
|
|
||||||
if matches!(auth_mode, AuthMode::Session | AuthMode::Both) {
|
|
||||||
if let Some(user) = try_session_auth(parts).await? {
|
|
||||||
return Ok(CurrentUser(user));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(ApiError::Unauthorized("Not authenticated".to_string()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Try to 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)
|
|
||||||
}
|
}
|
||||||
|
|||||||
107
api/src/main.rs
107
api/src/main.rs
@@ -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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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?;
|
.await?
|
||||||
|
.ok_or_else(|| ApiError::Unauthorized("Invalid credentials".to_string()))?;
|
||||||
|
|
||||||
let auth_mode = state.config.auth_mode;
|
let hash = user
|
||||||
|
.password_hash
|
||||||
|
.as_deref()
|
||||||
|
.ok_or_else(|| ApiError::Unauthorized("Invalid credentials".to_string()))?;
|
||||||
|
|
||||||
// In session or both mode, create session
|
if !infra::auth::verify_password(payload.password.as_ref(), hash) {
|
||||||
if matches!(auth_mode, AuthMode::Session | AuthMode::Both) {
|
return Err(ApiError::Unauthorized("Invalid credentials".to_string()));
|
||||||
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
|
let token = create_jwt(&user, &state)?;
|
||||||
#[cfg(feature = "auth-jwt")]
|
|
||||||
if matches!(auth_mode, AuthMode::Jwt | AuthMode::Both) {
|
Ok((
|
||||||
let token = create_jwt_for_user(&user, &state)?;
|
StatusCode::OK,
|
||||||
return Ok((
|
Json(TokenResponse {
|
||||||
StatusCode::CREATED,
|
|
||||||
Json(LoginResponse::Token(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,
|
||||||
})),
|
}),
|
||||||
));
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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?;
|
||||||
|
|
||||||
|
let token = create_jwt(&user, &state)?;
|
||||||
|
|
||||||
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(),
|
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)?"
|
||||||
|
|||||||
@@ -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"] }
|
||||||
|
|||||||
@@ -57,8 +57,4 @@ impl User {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper to get email as string
|
|
||||||
pub fn email_str(&self) -> &str {
|
|
||||||
self.email.as_ref()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
|
|||||||
@@ -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 {
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use axum_login::{AuthnBackend, UserId};
|
|
||||||
use password_auth::verify_password;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use tower_sessions::SessionManagerLayer;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use 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 {
|
pub fn hash_password(password: &str) -> String {
|
||||||
password_auth::generate_hash(password)
|
password_auth::generate_hash(password)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Verify a password against a stored hash
|
||||||
|
pub fn verify_password(password: &str, hash: &str) -> bool {
|
||||||
|
password_auth::verify_password(password, hash).is_ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "auth-oidc")]
|
#[cfg(feature = "auth-oidc")]
|
||||||
|
|||||||
@@ -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"))?;
|
||||||
|
|
||||||
|
|||||||
@@ -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()))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
pub use k_core::session::store::InfraSessionStore;
|
|
||||||
pub use tower_sessions::{Expiry, SessionManagerLayer};
|
|
||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user