Merge pull request #55 from readur/feat/oidc-setup
feat(server): set up oidc system and migrations
This commit is contained in:
commit
b095cb951f
|
|
@ -183,6 +183,49 @@ version = "0.7.6"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "assert-json-diff"
|
||||
version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-channel"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35"
|
||||
dependencies = [
|
||||
"concurrent-queue",
|
||||
"event-listener 2.5.3",
|
||||
"futures-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-stream"
|
||||
version = "0.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
|
||||
dependencies = [
|
||||
"async-stream-impl",
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-stream-impl"
|
||||
version = "0.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.103",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.88"
|
||||
|
|
@ -257,7 +300,7 @@ dependencies = [
|
|||
"aws-smithy-types",
|
||||
"aws-types",
|
||||
"bytes",
|
||||
"fastrand",
|
||||
"fastrand 2.3.0",
|
||||
"hex",
|
||||
"http 1.3.1",
|
||||
"ring",
|
||||
|
|
@ -319,7 +362,7 @@ dependencies = [
|
|||
"aws-smithy-types",
|
||||
"aws-types",
|
||||
"bytes",
|
||||
"fastrand",
|
||||
"fastrand 2.3.0",
|
||||
"http 0.2.12",
|
||||
"http-body 0.4.6",
|
||||
"percent-encoding",
|
||||
|
|
@ -348,7 +391,7 @@ dependencies = [
|
|||
"aws-smithy-xml",
|
||||
"aws-types",
|
||||
"bytes",
|
||||
"fastrand",
|
||||
"fastrand 2.3.0",
|
||||
"hex",
|
||||
"hmac",
|
||||
"http 0.2.12",
|
||||
|
|
@ -378,7 +421,7 @@ dependencies = [
|
|||
"aws-smithy-types",
|
||||
"aws-types",
|
||||
"bytes",
|
||||
"fastrand",
|
||||
"fastrand 2.3.0",
|
||||
"http 0.2.12",
|
||||
"regex-lite",
|
||||
"tracing",
|
||||
|
|
@ -400,7 +443,7 @@ dependencies = [
|
|||
"aws-smithy-types",
|
||||
"aws-types",
|
||||
"bytes",
|
||||
"fastrand",
|
||||
"fastrand 2.3.0",
|
||||
"http 0.2.12",
|
||||
"regex-lite",
|
||||
"tracing",
|
||||
|
|
@ -423,7 +466,7 @@ dependencies = [
|
|||
"aws-smithy-types",
|
||||
"aws-smithy-xml",
|
||||
"aws-types",
|
||||
"fastrand",
|
||||
"fastrand 2.3.0",
|
||||
"http 0.2.12",
|
||||
"regex-lite",
|
||||
"tracing",
|
||||
|
|
@ -590,7 +633,7 @@ dependencies = [
|
|||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-types",
|
||||
"bytes",
|
||||
"fastrand",
|
||||
"fastrand 2.3.0",
|
||||
"http 0.2.12",
|
||||
"http 1.3.1",
|
||||
"http-body 0.4.6",
|
||||
|
|
@ -694,7 +737,7 @@ dependencies = [
|
|||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"sync_wrapper 1.0.2",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
|
|
@ -716,7 +759,7 @@ dependencies = [
|
|||
"mime",
|
||||
"pin-project-lite",
|
||||
"rustversion",
|
||||
"sync_wrapper",
|
||||
"sync_wrapper 1.0.2",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
|
|
@ -743,6 +786,12 @@ version = "0.1.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "349a06037c7bf932dd7e7d1f653678b2038b9ad46a74102f1fc7bd7872678cce"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.21.7"
|
||||
|
|
@ -1328,6 +1377,25 @@ dependencies = [
|
|||
"syn 2.0.103",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deadpool"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "421fe0f90f2ab22016f32a9881be5134fdd71c65298917084b0c7477cbc3856e"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"deadpool-runtime",
|
||||
"num_cpus",
|
||||
"retain_mut",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deadpool-runtime"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b"
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.6.1"
|
||||
|
|
@ -1548,6 +1616,12 @@ dependencies = [
|
|||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "event-listener"
|
||||
version = "2.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0"
|
||||
|
||||
[[package]]
|
||||
name = "event-listener"
|
||||
version = "5.4.0"
|
||||
|
|
@ -1574,6 +1648,15 @@ dependencies = [
|
|||
"zune-inflate",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be"
|
||||
dependencies = [
|
||||
"instant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.3.0"
|
||||
|
|
@ -1743,6 +1826,21 @@ version = "0.3.31"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
|
||||
|
||||
[[package]]
|
||||
name = "futures-lite"
|
||||
version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce"
|
||||
dependencies = [
|
||||
"fastrand 1.9.0",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"memchr",
|
||||
"parking",
|
||||
"pin-project-lite",
|
||||
"waker-fn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.31"
|
||||
|
|
@ -1766,6 +1864,12 @@ version = "0.3.31"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
|
||||
|
||||
[[package]]
|
||||
name = "futures-timer"
|
||||
version = "3.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.31"
|
||||
|
|
@ -1794,6 +1898,17 @@ dependencies = [
|
|||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.1.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi 0.9.0+wasi-snapshot-preview1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.16"
|
||||
|
|
@ -1932,6 +2047,12 @@ version = "0.5.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
||||
|
||||
[[package]]
|
||||
name = "hex"
|
||||
version = "0.4.3"
|
||||
|
|
@ -2038,6 +2159,27 @@ version = "0.4.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
|
||||
|
||||
[[package]]
|
||||
name = "http-types"
|
||||
version = "2.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e9b187a72d63adbfba487f48095306ac823049cb504ee195541e91c7775f5ad"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
"base64 0.13.1",
|
||||
"futures-lite",
|
||||
"http 0.2.12",
|
||||
"infer",
|
||||
"pin-project-lite",
|
||||
"rand 0.7.3",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_qs",
|
||||
"serde_urlencoded",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
|
|
@ -2178,7 +2320,7 @@ dependencies = [
|
|||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"system-configuration",
|
||||
"system-configuration 0.6.1",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
|
|
@ -2416,6 +2558,12 @@ dependencies = [
|
|||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "infer"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "64e9829a50b42bb782c1df523f78d332fe371b10c661e78b7a3c34b0198e9fac"
|
||||
|
||||
[[package]]
|
||||
name = "inotify"
|
||||
version = "0.11.0"
|
||||
|
|
@ -2446,6 +2594,15 @@ dependencies = [
|
|||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "instant"
|
||||
version = "0.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "interpolate_name"
|
||||
version = "0.2.4"
|
||||
|
|
@ -3077,6 +3234,36 @@ dependencies = [
|
|||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_cpus"
|
||||
version = "1.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oauth2"
|
||||
version = "4.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c38841cdd844847e3e7c8d29cef9dcfed8877f8f56f9071f77843ecf3baf937f"
|
||||
dependencies = [
|
||||
"base64 0.13.1",
|
||||
"chrono",
|
||||
"getrandom 0.2.16",
|
||||
"http 0.2.12",
|
||||
"rand 0.8.5",
|
||||
"reqwest 0.11.27",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"sha2",
|
||||
"thiserror 1.0.69",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-foundation"
|
||||
version = "0.3.1"
|
||||
|
|
@ -3477,6 +3664,19 @@ version = "5.2.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03"
|
||||
dependencies = [
|
||||
"getrandom 0.1.16",
|
||||
"libc",
|
||||
"rand_chacha 0.2.2",
|
||||
"rand_core 0.5.1",
|
||||
"rand_hc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.5"
|
||||
|
|
@ -3498,6 +3698,16 @@ dependencies = [
|
|||
"rand_core 0.9.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.3.1"
|
||||
|
|
@ -3518,6 +3728,15 @@ dependencies = [
|
|||
"rand_core 0.9.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19"
|
||||
dependencies = [
|
||||
"getrandom 0.1.16",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
|
|
@ -3546,6 +3765,15 @@ dependencies = [
|
|||
"rand 0.8.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_hc"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c"
|
||||
dependencies = [
|
||||
"rand_core 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rangemap"
|
||||
version = "1.5.1"
|
||||
|
|
@ -3660,11 +3888,12 @@ dependencies = [
|
|||
"jsonwebtoken",
|
||||
"mime_guess",
|
||||
"notify",
|
||||
"oauth2",
|
||||
"pdf-extract",
|
||||
"quick-xml",
|
||||
"raw-cpuid",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"reqwest 0.12.20",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
|
|
@ -3676,16 +3905,19 @@ dependencies = [
|
|||
"testcontainers-modules",
|
||||
"thiserror 2.0.12",
|
||||
"tokio",
|
||||
"tokio-test",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"urlencoding",
|
||||
"utoipa",
|
||||
"utoipa-swagger-ui",
|
||||
"uuid",
|
||||
"walkdir",
|
||||
"wiremock",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3776,6 +4008,47 @@ version = "0.8.5"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.11.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62"
|
||||
dependencies = [
|
||||
"base64 0.21.7",
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2 0.3.26",
|
||||
"http 0.2.12",
|
||||
"http-body 0.4.6",
|
||||
"hyper 0.14.32",
|
||||
"hyper-rustls 0.24.2",
|
||||
"ipnet",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"once_cell",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustls 0.21.12",
|
||||
"rustls-pemfile 1.0.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper 0.1.2",
|
||||
"system-configuration 0.5.1",
|
||||
"tokio",
|
||||
"tokio-rustls 0.24.1",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"webpki-roots 0.25.4",
|
||||
"winreg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.20"
|
||||
|
|
@ -3806,7 +4079,7 @@ dependencies = [
|
|||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"sync_wrapper 1.0.2",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tower",
|
||||
|
|
@ -3818,6 +4091,12 @@ dependencies = [
|
|||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "retain_mut"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4389f1d5789befaf6029ebd9f7dac4af7f7e3d61b69d4f30e2ac02b57e7712b0"
|
||||
|
||||
[[package]]
|
||||
name = "rfc6979"
|
||||
version = "0.3.1"
|
||||
|
|
@ -4215,6 +4494,17 @@ dependencies = [
|
|||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_qs"
|
||||
version = "0.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7715380eec75f029a4ef7de39a9200e0a63823176b759d055b613f5a87df6a6"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_repr"
|
||||
version = "0.1.20"
|
||||
|
|
@ -4463,7 +4753,7 @@ dependencies = [
|
|||
"crc",
|
||||
"crossbeam-queue",
|
||||
"either",
|
||||
"event-listener",
|
||||
"event-listener 5.4.0",
|
||||
"futures-core",
|
||||
"futures-intrusive",
|
||||
"futures-io",
|
||||
|
|
@ -4710,6 +5000,12 @@ dependencies = [
|
|||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sync_wrapper"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160"
|
||||
|
||||
[[package]]
|
||||
name = "sync_wrapper"
|
||||
version = "1.0.2"
|
||||
|
|
@ -4744,6 +5040,17 @@ dependencies = [
|
|||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"core-foundation 0.9.4",
|
||||
"system-configuration-sys 0.5.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration"
|
||||
version = "0.6.1"
|
||||
|
|
@ -4752,7 +5059,17 @@ checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
|
|||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"core-foundation 0.9.4",
|
||||
"system-configuration-sys",
|
||||
"system-configuration-sys 0.6.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration-sys"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -4790,7 +5107,7 @@ version = "3.20.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"fastrand 2.3.0",
|
||||
"getrandom 0.3.3",
|
||||
"once_cell",
|
||||
"rustix 1.0.7",
|
||||
|
|
@ -5070,6 +5387,19 @@ dependencies = [
|
|||
"xattr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-test"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2468baabc3311435b55dd935f702f42cd1b8abb7e754fb7dfb16bd36aa88f9f7"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.15"
|
||||
|
|
@ -5126,7 +5456,7 @@ dependencies = [
|
|||
"futures-core",
|
||||
"futures-util",
|
||||
"pin-project-lite",
|
||||
"sync_wrapper",
|
||||
"sync_wrapper 1.0.2",
|
||||
"tokio",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
|
|
@ -5427,6 +5757,12 @@ version = "0.8.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64"
|
||||
|
||||
[[package]]
|
||||
name = "waker-fn"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7"
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
|
|
@ -5446,6 +5782,12 @@ dependencies = [
|
|||
"try-lock",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.9.0+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
|
|
@ -5548,6 +5890,12 @@ dependencies = [
|
|||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.25.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1"
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
|
|
@ -5914,6 +6262,38 @@ dependencies = [
|
|||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.50.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wiremock"
|
||||
version = "0.5.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13a3a53eaf34f390dd30d7b1b078287dd05df2aa2e21a589ccb80f5c7253c2e9"
|
||||
dependencies = [
|
||||
"assert-json-diff",
|
||||
"async-trait",
|
||||
"base64 0.21.7",
|
||||
"deadpool",
|
||||
"futures",
|
||||
"futures-timer",
|
||||
"http-types",
|
||||
"hyper 0.14.32",
|
||||
"log",
|
||||
"once_cell",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rt"
|
||||
version = "0.39.0"
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ raw-cpuid = { version = "11", optional = true }
|
|||
reqwest = { version = "0.12", features = ["json", "multipart"] }
|
||||
quick-xml = { version = "0.37", features = ["serialize"] }
|
||||
urlencoding = "2.1"
|
||||
oauth2 = "4.4"
|
||||
url = "2.4"
|
||||
dotenvy = "0.15"
|
||||
hostname = "0.4"
|
||||
walkdir = "2"
|
||||
|
|
@ -67,6 +69,8 @@ test-utils = ["testcontainers", "testcontainers-modules"]
|
|||
tempfile = "3"
|
||||
testcontainers = "0.24"
|
||||
testcontainers-modules = { version = "0.12", features = ["postgres"] }
|
||||
wiremock = "0.5"
|
||||
tokio-test = "0.4"
|
||||
|
||||
[profile.test]
|
||||
incremental = false
|
||||
|
|
|
|||
|
|
@ -0,0 +1,439 @@
|
|||
# OIDC Authentication Setup Guide
|
||||
|
||||
This guide explains how to configure OpenID Connect (OIDC) authentication for Readur, allowing users to sign in using external identity providers like Google, Microsoft Azure AD, Keycloak, Auth0, or any OIDC-compliant provider.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Configuration](#configuration)
|
||||
- [Environment Variables](#environment-variables)
|
||||
- [Example Configurations](#example-configurations)
|
||||
- [Identity Provider Setup](#identity-provider-setup)
|
||||
- [Google OAuth 2.0](#google-oauth-20)
|
||||
- [Microsoft Azure AD](#microsoft-azure-ad)
|
||||
- [Keycloak](#keycloak)
|
||||
- [Auth0](#auth0)
|
||||
- [Generic OIDC Provider](#generic-oidc-provider)
|
||||
- [Testing the Setup](#testing-the-setup)
|
||||
- [User Experience](#user-experience)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Security Considerations](#security-considerations)
|
||||
|
||||
## Overview
|
||||
|
||||
OIDC authentication in Readur provides:
|
||||
|
||||
- **Single Sign-On (SSO)**: Users can sign in with existing corporate accounts
|
||||
- **Centralized User Management**: User provisioning handled by your identity provider
|
||||
- **Enhanced Security**: No need to manage passwords in Readur
|
||||
- **Seamless Integration**: Works alongside existing local authentication
|
||||
|
||||
When OIDC is enabled, users will see a "Sign in with OIDC" button on the login page alongside the standard username/password form.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before configuring OIDC, ensure you have:
|
||||
|
||||
1. **Access to an OIDC Provider**: Google, Microsoft, Keycloak, Auth0, etc.
|
||||
2. **Ability to Register Applications**: Admin access to create OAuth2/OIDC applications
|
||||
3. **Network Connectivity**: Readur server can reach the OIDC provider endpoints
|
||||
4. **SSL/TLS Setup**: HTTPS is strongly recommended for production deployments
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Configure OIDC by setting these environment variables:
|
||||
|
||||
| Variable | Required | Description | Example |
|
||||
|----------|----------|-------------|---------|
|
||||
| `OIDC_ENABLED` | ✅ | Enable OIDC authentication | `true` |
|
||||
| `OIDC_CLIENT_ID` | ✅ | OAuth2 client ID from your provider | `readur-app-client-id` |
|
||||
| `OIDC_CLIENT_SECRET` | ✅ | OAuth2 client secret from your provider | `very-secret-key` |
|
||||
| `OIDC_ISSUER_URL` | ✅ | OIDC provider's issuer URL | `https://accounts.google.com` |
|
||||
| `OIDC_REDIRECT_URI` | ✅ | Callback URL for your Readur instance | `https://readur.company.com/auth/oidc/callback` |
|
||||
|
||||
### Example Configurations
|
||||
|
||||
#### Basic OIDC Setup
|
||||
|
||||
```env
|
||||
# Enable OIDC
|
||||
OIDC_ENABLED=true
|
||||
|
||||
# Provider settings (example for Google)
|
||||
OIDC_CLIENT_ID=123456789-abcdefgh.apps.googleusercontent.com
|
||||
OIDC_CLIENT_SECRET=GOCSPX-your-secret-key
|
||||
OIDC_ISSUER_URL=https://accounts.google.com
|
||||
OIDC_REDIRECT_URI=https://readur.company.com/auth/oidc/callback
|
||||
```
|
||||
|
||||
#### Development Setup
|
||||
|
||||
```env
|
||||
# Enable OIDC for development
|
||||
OIDC_ENABLED=true
|
||||
|
||||
# Local development settings
|
||||
OIDC_CLIENT_ID=dev-client-id
|
||||
OIDC_CLIENT_SECRET=dev-client-secret
|
||||
OIDC_ISSUER_URL=https://your-keycloak.company.com/auth/realms/readur
|
||||
OIDC_REDIRECT_URI=http://localhost:8000/auth/oidc/callback
|
||||
```
|
||||
|
||||
#### Docker Compose Setup
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
services:
|
||||
readur:
|
||||
image: readur:latest
|
||||
environment:
|
||||
# Core settings
|
||||
DATABASE_URL: postgresql://readur:readur@postgres:5432/readur
|
||||
|
||||
# OIDC configuration
|
||||
OIDC_ENABLED: "true"
|
||||
OIDC_CLIENT_ID: "${OIDC_CLIENT_ID}"
|
||||
OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET}"
|
||||
OIDC_ISSUER_URL: "${OIDC_ISSUER_URL}"
|
||||
OIDC_REDIRECT_URI: "https://readur.company.com/auth/oidc/callback"
|
||||
ports:
|
||||
- "8000:8000"
|
||||
```
|
||||
|
||||
## Identity Provider Setup
|
||||
|
||||
### Google OAuth 2.0
|
||||
|
||||
1. **Create a Project** in [Google Cloud Console](https://console.cloud.google.com/)
|
||||
|
||||
2. **Enable Google+ API**:
|
||||
- Go to "APIs & Services" → "Library"
|
||||
- Search for "Google+ API" and enable it
|
||||
|
||||
3. **Create OAuth 2.0 Credentials**:
|
||||
- Go to "APIs & Services" → "Credentials"
|
||||
- Click "Create Credentials" → "OAuth 2.0 Client ID"
|
||||
- Application type: "Web application"
|
||||
- Name: "Readur Document Management"
|
||||
|
||||
4. **Configure Redirect URIs**:
|
||||
```
|
||||
Authorized redirect URIs:
|
||||
https://your-readur-domain.com/auth/oidc/callback
|
||||
http://localhost:8000/auth/oidc/callback (for development)
|
||||
```
|
||||
|
||||
5. **Environment Variables**:
|
||||
```env
|
||||
OIDC_ENABLED=true
|
||||
OIDC_CLIENT_ID=123456789-abcdefgh.apps.googleusercontent.com
|
||||
OIDC_CLIENT_SECRET=GOCSPX-your-secret-key
|
||||
OIDC_ISSUER_URL=https://accounts.google.com
|
||||
OIDC_REDIRECT_URI=https://your-readur-domain.com/auth/oidc/callback
|
||||
```
|
||||
|
||||
### Microsoft Azure AD
|
||||
|
||||
1. **Register an Application** in [Azure Portal](https://portal.azure.com/):
|
||||
- Go to "Azure Active Directory" → "App registrations"
|
||||
- Click "New registration"
|
||||
- Name: "Readur Document Management"
|
||||
- Supported account types: Choose based on your needs
|
||||
- Redirect URI: `https://your-readur-domain.com/auth/oidc/callback`
|
||||
|
||||
2. **Configure Authentication**:
|
||||
- In your app registration, go to "Authentication"
|
||||
- Add platform: "Web"
|
||||
- Add redirect URIs as needed
|
||||
- Enable "ID tokens" under "Implicit grant and hybrid flows"
|
||||
|
||||
3. **Create Client Secret**:
|
||||
- Go to "Certificates & secrets"
|
||||
- Click "New client secret"
|
||||
- Add description and choose expiration
|
||||
- **Copy the secret value immediately** (you won't see it again)
|
||||
|
||||
4. **Get Tenant Information**:
|
||||
- Note your Tenant ID from the "Overview" page
|
||||
- Issuer URL format: `https://login.microsoftonline.com/{tenant-id}/v2.0`
|
||||
|
||||
5. **Environment Variables**:
|
||||
```env
|
||||
OIDC_ENABLED=true
|
||||
OIDC_CLIENT_ID=12345678-1234-1234-1234-123456789012
|
||||
OIDC_CLIENT_SECRET=your-client-secret
|
||||
OIDC_ISSUER_URL=https://login.microsoftonline.com/your-tenant-id/v2.0
|
||||
OIDC_REDIRECT_URI=https://your-readur-domain.com/auth/oidc/callback
|
||||
```
|
||||
|
||||
### Keycloak
|
||||
|
||||
1. **Create a Realm** (or use existing):
|
||||
- Access Keycloak admin console
|
||||
- Create or select a realm for Readur
|
||||
|
||||
2. **Create a Client**:
|
||||
- Go to "Clients" → "Create"
|
||||
- Client ID: `readur`
|
||||
- Client Protocol: `openid-connect`
|
||||
- Root URL: `https://your-readur-domain.com`
|
||||
|
||||
3. **Configure Client Settings**:
|
||||
- Access Type: `confidential`
|
||||
- Standard Flow Enabled: `ON`
|
||||
- Valid Redirect URIs: `https://your-readur-domain.com/auth/oidc/callback*`
|
||||
- Web Origins: `https://your-readur-domain.com`
|
||||
|
||||
4. **Get Client Secret**:
|
||||
- Go to "Credentials" tab
|
||||
- Copy the client secret
|
||||
|
||||
5. **Environment Variables**:
|
||||
```env
|
||||
OIDC_ENABLED=true
|
||||
OIDC_CLIENT_ID=readur
|
||||
OIDC_CLIENT_SECRET=your-keycloak-client-secret
|
||||
OIDC_ISSUER_URL=https://keycloak.company.com/auth/realms/your-realm
|
||||
OIDC_REDIRECT_URI=https://your-readur-domain.com/auth/oidc/callback
|
||||
```
|
||||
|
||||
### Auth0
|
||||
|
||||
1. **Create an Application** in [Auth0 Dashboard](https://manage.auth0.com/):
|
||||
- Go to "Applications" → "Create Application"
|
||||
- Name: "Readur Document Management"
|
||||
- Application Type: "Regular Web Applications"
|
||||
|
||||
2. **Configure Settings**:
|
||||
- Allowed Callback URLs: `https://your-readur-domain.com/auth/oidc/callback`
|
||||
- Allowed Web Origins: `https://your-readur-domain.com`
|
||||
- Allowed Logout URLs: `https://your-readur-domain.com/login`
|
||||
|
||||
3. **Get Credentials**:
|
||||
- Note the Client ID and Client Secret from the "Settings" tab
|
||||
- Domain will be something like `your-app.auth0.com`
|
||||
|
||||
4. **Environment Variables**:
|
||||
```env
|
||||
OIDC_ENABLED=true
|
||||
OIDC_CLIENT_ID=your-auth0-client-id
|
||||
OIDC_CLIENT_SECRET=your-auth0-client-secret
|
||||
OIDC_ISSUER_URL=https://your-app.auth0.com
|
||||
OIDC_REDIRECT_URI=https://your-readur-domain.com/auth/oidc/callback
|
||||
```
|
||||
|
||||
### Generic OIDC Provider
|
||||
|
||||
For any OIDC-compliant provider:
|
||||
|
||||
1. **Register Your Application** with the provider
|
||||
2. **Configure Redirect URI**: `https://your-readur-domain.com/auth/oidc/callback`
|
||||
3. **Get Credentials**: Client ID, Client Secret, and Issuer URL
|
||||
4. **Set Environment Variables**:
|
||||
```env
|
||||
OIDC_ENABLED=true
|
||||
OIDC_CLIENT_ID=your-client-id
|
||||
OIDC_CLIENT_SECRET=your-client-secret
|
||||
OIDC_ISSUER_URL=https://your-provider.com
|
||||
OIDC_REDIRECT_URI=https://your-readur-domain.com/auth/oidc/callback
|
||||
```
|
||||
|
||||
## Testing the Setup
|
||||
|
||||
### 1. Verify Configuration Loading
|
||||
|
||||
When starting Readur, check the logs for OIDC configuration:
|
||||
|
||||
```
|
||||
✅ OIDC_ENABLED: true (loaded from env)
|
||||
✅ OIDC_CLIENT_ID: your-client-id (loaded from env)
|
||||
✅ OIDC_CLIENT_SECRET: ***hidden*** (loaded from env, 32 chars)
|
||||
✅ OIDC_ISSUER_URL: https://accounts.google.com (loaded from env)
|
||||
✅ OIDC_REDIRECT_URI: https://your-domain.com/auth/oidc/callback (loaded from env)
|
||||
```
|
||||
|
||||
### 2. Test Discovery Endpoint
|
||||
|
||||
Verify your provider's discovery endpoint works:
|
||||
|
||||
```bash
|
||||
curl https://accounts.google.com/.well-known/openid-configuration
|
||||
```
|
||||
|
||||
Should return JSON with `authorization_endpoint`, `token_endpoint`, and `userinfo_endpoint`.
|
||||
|
||||
### 3. Test Login Flow
|
||||
|
||||
1. Navigate to your Readur login page
|
||||
2. Click "Sign in with OIDC"
|
||||
3. You should be redirected to your identity provider
|
||||
4. After authentication, you should be redirected back to Readur dashboard
|
||||
|
||||
### 4. Check User Creation
|
||||
|
||||
Verify that OIDC users are created correctly:
|
||||
|
||||
- Check database for new users with `auth_provider = 'oidc'`
|
||||
- Ensure `oidc_subject`, `oidc_issuer`, and `oidc_email` fields are populated
|
||||
- Verify users can access the dashboard
|
||||
|
||||
## User Experience
|
||||
|
||||
### First-Time Login
|
||||
|
||||
When a user signs in with OIDC for the first time:
|
||||
|
||||
1. User clicks "Sign in with OIDC"
|
||||
2. Redirected to identity provider for authentication
|
||||
3. After successful authentication, a new Readur account is created
|
||||
4. User information is populated from OIDC claims:
|
||||
- **Username**: Derived from `preferred_username` or `email`
|
||||
- **Email**: From `email` claim
|
||||
- **OIDC Subject**: Unique identifier from `sub` claim
|
||||
- **Auth Provider**: Set to `oidc`
|
||||
|
||||
### Subsequent Logins
|
||||
|
||||
For returning users:
|
||||
|
||||
1. User clicks "Sign in with OIDC"
|
||||
2. Readur matches the user by `oidc_subject` and `oidc_issuer`
|
||||
3. User is automatically signed in without creating a duplicate account
|
||||
|
||||
### Mixed Authentication
|
||||
|
||||
- Local users can continue using username/password
|
||||
- OIDC users are created as separate accounts
|
||||
- Administrators can manage both types of users
|
||||
- No automatic account linking between local and OIDC accounts
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### "OIDC client ID not configured"
|
||||
|
||||
**Problem**: OIDC environment variables not set correctly
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
# Verify environment variables are set
|
||||
echo $OIDC_ENABLED
|
||||
echo $OIDC_CLIENT_ID
|
||||
echo $OIDC_ISSUER_URL
|
||||
|
||||
# Check for typos in variable names
|
||||
env | grep OIDC
|
||||
```
|
||||
|
||||
#### "Failed to discover OIDC endpoints"
|
||||
|
||||
**Problem**: Cannot reach the OIDC discovery endpoint
|
||||
|
||||
**Solutions**:
|
||||
- Verify `OIDC_ISSUER_URL` is correct
|
||||
- Test connectivity: `curl https://your-issuer/.well-known/openid-configuration`
|
||||
- Check firewall and network settings
|
||||
- Ensure DNS resolution works
|
||||
|
||||
#### "Invalid redirect_uri"
|
||||
|
||||
**Problem**: Redirect URI mismatch between Readur and identity provider
|
||||
|
||||
**Solutions**:
|
||||
- Verify `OIDC_REDIRECT_URI` matches exactly in both places
|
||||
- Check for trailing slashes, HTTP vs HTTPS
|
||||
- Ensure the provider allows your redirect URI
|
||||
|
||||
#### "Authentication failed: access_denied"
|
||||
|
||||
**Problem**: User denied access or provider restrictions
|
||||
|
||||
**Solutions**:
|
||||
- Check user permissions in identity provider
|
||||
- Verify the application is enabled for the user
|
||||
- Review provider-specific restrictions
|
||||
|
||||
#### "Invalid authorization code"
|
||||
|
||||
**Problem**: Issues with the OAuth2 flow
|
||||
|
||||
**Solutions**:
|
||||
- Check system clock synchronization
|
||||
- Verify client secret is correct
|
||||
- Look for network issues during token exchange
|
||||
|
||||
### Debug Mode
|
||||
|
||||
Enable detailed logging for OIDC troubleshooting:
|
||||
|
||||
```env
|
||||
RUST_LOG=debug
|
||||
```
|
||||
|
||||
This will show detailed information about:
|
||||
- OIDC discovery process
|
||||
- Token exchange
|
||||
- User information retrieval
|
||||
- Error details
|
||||
|
||||
### Testing with curl
|
||||
|
||||
Test the callback endpoint manually:
|
||||
|
||||
```bash
|
||||
# Test the OIDC callback endpoint (after getting an auth code)
|
||||
curl -X GET "https://your-readur-domain.com/api/auth/oidc/callback?code=AUTH_CODE&state=STATE"
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Production Deployment
|
||||
|
||||
1. **Use HTTPS**: Always use HTTPS in production
|
||||
```env
|
||||
OIDC_REDIRECT_URI=https://readur.company.com/auth/oidc/callback
|
||||
```
|
||||
|
||||
2. **Secure Client Secret**: Store client secrets securely
|
||||
- Use environment variables or secret management systems
|
||||
- Never commit secrets to version control
|
||||
- Rotate secrets regularly
|
||||
|
||||
3. **Validate Redirect URIs**: Ensure your identity provider only allows valid redirect URIs
|
||||
|
||||
4. **Network Security**: Restrict network access between Readur and identity provider
|
||||
|
||||
### User Management
|
||||
|
||||
1. **Account Mapping**: OIDC users are identified by `oidc_subject` + `oidc_issuer`
|
||||
2. **No Password**: OIDC users don't have passwords in Readur
|
||||
3. **User Deletion**: Deleting users from identity provider doesn't automatically remove them from Readur
|
||||
4. **Role Management**: Configure user roles in Readur or map from OIDC claims
|
||||
|
||||
### Monitoring
|
||||
|
||||
Monitor OIDC authentication:
|
||||
|
||||
- Failed authentication attempts
|
||||
- Token validation errors
|
||||
- User creation patterns
|
||||
- Provider availability
|
||||
|
||||
## Next Steps
|
||||
|
||||
After setting up OIDC:
|
||||
|
||||
1. **Test Thoroughly**: Test with different user accounts and scenarios
|
||||
2. **User Training**: Inform users about the new login option
|
||||
3. **Monitor Usage**: Track authentication patterns and issues
|
||||
4. **Backup Strategy**: Ensure you can recover access if OIDC provider is unavailable
|
||||
5. **Documentation**: Document your specific provider configuration for your team
|
||||
|
||||
For additional help:
|
||||
- Review the [configuration guide](configuration.md) for general settings
|
||||
- Check the [deployment guide](deployment.md) for production setup
|
||||
- See the [user guide](user-guide.md) for end-user documentation
|
||||
|
|
@ -5,6 +5,7 @@ import { useAuth } from './contexts/AuthContext';
|
|||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { NotificationProvider } from './contexts/NotificationContext';
|
||||
import Login from './components/Auth/Login';
|
||||
import OidcCallback from './components/Auth/OidcCallback';
|
||||
import AppLayout from './components/Layout/AppLayout';
|
||||
import Dashboard from './components/Dashboard/Dashboard';
|
||||
import UploadPage from './pages/UploadPage';
|
||||
|
|
@ -56,6 +57,7 @@ function App(): React.ReactElement {
|
|||
<CssBaseline />
|
||||
<Routes>
|
||||
<Route path="/login" element={!user ? <Login /> : <Navigate to="/dashboard" />} />
|
||||
<Route path="/auth/oidc/callback" element={<OidcCallback />} />
|
||||
<Route
|
||||
path="/*"
|
||||
element={
|
||||
|
|
|
|||
|
|
@ -19,12 +19,14 @@ import {
|
|||
Email as EmailIcon,
|
||||
Lock as LockIcon,
|
||||
CloudUpload as LogoIcon,
|
||||
Security as SecurityIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { useForm, SubmitHandler } from 'react-hook-form';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { useTheme as useMuiTheme } from '@mui/material/styles';
|
||||
import { api } from '../../services/api';
|
||||
|
||||
interface LoginFormData {
|
||||
username: string;
|
||||
|
|
@ -35,6 +37,7 @@ const Login: React.FC = () => {
|
|||
const [showPassword, setShowPassword] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string>('');
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [oidcLoading, setOidcLoading] = useState<boolean>(false);
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { mode } = useTheme();
|
||||
|
|
@ -63,6 +66,18 @@ const Login: React.FC = () => {
|
|||
setShowPassword(!showPassword);
|
||||
};
|
||||
|
||||
const handleOidcLogin = async (): Promise<void> => {
|
||||
try {
|
||||
setError('');
|
||||
setOidcLoading(true);
|
||||
// Redirect to OIDC login endpoint
|
||||
window.location.href = '/api/auth/oidc/login';
|
||||
} catch (err) {
|
||||
setError('Failed to initiate OIDC login. Please try again.');
|
||||
setOidcLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
|
|
@ -221,7 +236,7 @@ const Login: React.FC = () => {
|
|||
fullWidth
|
||||
variant="contained"
|
||||
size="large"
|
||||
disabled={loading}
|
||||
disabled={loading || oidcLoading}
|
||||
sx={{
|
||||
py: 1.5,
|
||||
mb: 2,
|
||||
|
|
@ -243,6 +258,66 @@ const Login: React.FC = () => {
|
|||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</Button>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
my: 2,
|
||||
'&::before': {
|
||||
content: '""',
|
||||
flex: 1,
|
||||
height: '1px',
|
||||
backgroundColor: 'divider',
|
||||
},
|
||||
'&::after': {
|
||||
content: '""',
|
||||
flex: 1,
|
||||
height: '1px',
|
||||
backgroundColor: 'divider',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
px: 2,
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
or
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
size="large"
|
||||
disabled={loading || oidcLoading}
|
||||
onClick={handleOidcLogin}
|
||||
startIcon={<SecurityIcon />}
|
||||
sx={{
|
||||
py: 1.5,
|
||||
mb: 2,
|
||||
borderRadius: 2,
|
||||
fontSize: '1rem',
|
||||
fontWeight: 600,
|
||||
textTransform: 'none',
|
||||
borderColor: 'primary.main',
|
||||
color: 'primary.main',
|
||||
'&:hover': {
|
||||
backgroundColor: 'primary.main',
|
||||
color: 'white',
|
||||
borderColor: 'primary.main',
|
||||
},
|
||||
'&:disabled': {
|
||||
borderColor: 'rgba(0, 0, 0, 0.12)',
|
||||
color: 'rgba(0, 0, 0, 0.26)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{oidcLoading ? 'Redirecting...' : 'Sign in with OIDC'}
|
||||
</Button>
|
||||
|
||||
<Box sx={{ textAlign: 'center', mt: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Demo credentials: admin / readur2024
|
||||
|
|
|
|||
|
|
@ -0,0 +1,131 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { Box, CircularProgress, Typography, Alert, Container } from '@mui/material';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { api } from '../../services/api';
|
||||
|
||||
const OidcCallback: React.FC = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const { login } = useAuth();
|
||||
const [error, setError] = useState<string>('');
|
||||
const [processing, setProcessing] = useState<boolean>(true);
|
||||
|
||||
useEffect(() => {
|
||||
const handleCallback = async () => {
|
||||
try {
|
||||
const code = searchParams.get('code');
|
||||
const error = searchParams.get('error');
|
||||
const state = searchParams.get('state');
|
||||
|
||||
if (error) {
|
||||
setError(`Authentication failed: ${error}`);
|
||||
setProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
setError('No authorization code received');
|
||||
setProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Call the backend OIDC callback endpoint
|
||||
const response = await api.get(`/auth/oidc/callback?code=${code}&state=${state || ''}`);
|
||||
|
||||
if (response.data && response.data.token) {
|
||||
// Store the token and user data
|
||||
localStorage.setItem('token', response.data.token);
|
||||
api.defaults.headers.common['Authorization'] = `Bearer ${response.data.token}`;
|
||||
|
||||
// Redirect to dashboard - the auth context will pick up the token on next page load
|
||||
window.location.href = '/dashboard';
|
||||
} else {
|
||||
setError('Invalid response from authentication server');
|
||||
setProcessing(false);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('OIDC callback error:', err);
|
||||
setError(
|
||||
err.response?.data?.error ||
|
||||
err.message ||
|
||||
'Failed to complete authentication'
|
||||
);
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
handleCallback();
|
||||
}, [searchParams, navigate, login]);
|
||||
|
||||
const handleReturnToLogin = () => {
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
p: 2,
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="sm">
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
borderRadius: 4,
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
}}
|
||||
>
|
||||
{processing ? (
|
||||
<>
|
||||
<CircularProgress size={60} sx={{ mb: 3, color: 'primary.main' }} />
|
||||
<Typography variant="h5" sx={{ mb: 2, fontWeight: 600 }}>
|
||||
Completing Authentication
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Please wait while we process your authentication...
|
||||
</Typography>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Alert
|
||||
severity="error"
|
||||
sx={{ mb: 3, textAlign: 'left' }}
|
||||
action={
|
||||
<Box
|
||||
component="button"
|
||||
onClick={handleReturnToLogin}
|
||||
sx={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'primary.main',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline',
|
||||
fontSize: '0.875rem',
|
||||
}}
|
||||
>
|
||||
Return to Login
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
Authentication Error
|
||||
</Typography>
|
||||
{error}
|
||||
</Alert>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default OidcCallback;
|
||||
|
|
@ -0,0 +1,211 @@
|
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { vi } from 'vitest';
|
||||
import Login from '../Login';
|
||||
import { AuthProvider } from '../../../contexts/AuthContext';
|
||||
import { ThemeProvider } from '../../../contexts/ThemeContext';
|
||||
|
||||
// Mock the API
|
||||
vi.mock('../../../services/api', () => ({
|
||||
api: {
|
||||
post: vi.fn(),
|
||||
defaults: {
|
||||
headers: {
|
||||
common: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock useNavigate
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate
|
||||
};
|
||||
});
|
||||
|
||||
// Mock localStorage
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: {
|
||||
setItem: vi.fn(),
|
||||
getItem: vi.fn(() => null),
|
||||
removeItem: vi.fn()
|
||||
}
|
||||
});
|
||||
|
||||
// Mock window.location
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
href: ''
|
||||
},
|
||||
writable: true
|
||||
});
|
||||
|
||||
|
||||
// Mock AuthContext
|
||||
const mockAuthContextValue = {
|
||||
user: null,
|
||||
loading: false,
|
||||
login: vi.fn(),
|
||||
register: vi.fn(),
|
||||
logout: vi.fn()
|
||||
};
|
||||
|
||||
const MockAuthProvider = ({ children }: { children: React.ReactNode }) => (
|
||||
<AuthProvider>
|
||||
{children}
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
const MockThemeProvider = ({ children }: { children: React.ReactNode }) => (
|
||||
<ThemeProvider>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
describe('Login - OIDC Features', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Mock window.matchMedia
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation(query => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
const renderLogin = () => {
|
||||
return render(
|
||||
<BrowserRouter>
|
||||
<MockThemeProvider>
|
||||
<MockAuthProvider>
|
||||
<Login />
|
||||
</MockAuthProvider>
|
||||
</MockThemeProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
it('renders OIDC login button', () => {
|
||||
renderLogin();
|
||||
|
||||
expect(screen.getByText('Sign in with OIDC')).toBeInTheDocument();
|
||||
expect(screen.getByText('or')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles OIDC login button click', async () => {
|
||||
renderLogin();
|
||||
|
||||
const oidcButton = screen.getByText('Sign in with OIDC');
|
||||
fireEvent.click(oidcButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.href).toBe('/api/auth/oidc/login');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows loading state when OIDC login is clicked', async () => {
|
||||
renderLogin();
|
||||
|
||||
const oidcButton = screen.getByText('Sign in with OIDC');
|
||||
fireEvent.click(oidcButton);
|
||||
|
||||
expect(screen.getByText('Redirecting...')).toBeInTheDocument();
|
||||
expect(oidcButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables regular login when OIDC is loading', async () => {
|
||||
renderLogin();
|
||||
|
||||
const oidcButton = screen.getByText('Sign in with OIDC');
|
||||
const regularButton = screen.getByText('Sign in');
|
||||
|
||||
fireEvent.click(oidcButton);
|
||||
|
||||
expect(regularButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows error message on OIDC login failure', async () => {
|
||||
// Mock an error during OIDC redirect
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
get href() {
|
||||
throw new Error('Network error');
|
||||
},
|
||||
set href(value) {
|
||||
throw new Error('Network error');
|
||||
}
|
||||
},
|
||||
configurable: true
|
||||
});
|
||||
|
||||
renderLogin();
|
||||
|
||||
const oidcButton = screen.getByText('Sign in with OIDC');
|
||||
fireEvent.click(oidcButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Failed to initiate OIDC login/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('has proper styling for OIDC button', () => {
|
||||
renderLogin();
|
||||
|
||||
const oidcButton = screen.getByText('Sign in with OIDC');
|
||||
const buttonElement = oidcButton.closest('button');
|
||||
|
||||
expect(buttonElement).toHaveClass('MuiButton-outlined');
|
||||
expect(buttonElement).toHaveAttribute('type', 'button');
|
||||
});
|
||||
|
||||
it('includes security icon in OIDC button', () => {
|
||||
renderLogin();
|
||||
|
||||
const oidcButton = screen.getByText('Sign in with OIDC');
|
||||
const buttonElement = oidcButton.closest('button');
|
||||
|
||||
// Check for security icon (via test id or class)
|
||||
expect(buttonElement?.querySelector('svg')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('maintains button accessibility', () => {
|
||||
renderLogin();
|
||||
|
||||
const oidcButton = screen.getByRole('button', { name: /sign in with oidc/i });
|
||||
expect(oidcButton).toBeInTheDocument();
|
||||
expect(oidcButton).toBeEnabled();
|
||||
});
|
||||
|
||||
it('handles keyboard navigation', () => {
|
||||
renderLogin();
|
||||
|
||||
const usernameInput = screen.getByLabelText(/username/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const regularButton = screen.getByText('Sign in');
|
||||
const oidcButton = screen.getByText('Sign in with OIDC');
|
||||
|
||||
// Tab order should be: username -> password -> sign in -> oidc
|
||||
usernameInput.focus();
|
||||
expect(document.activeElement).toBe(usernameInput);
|
||||
|
||||
fireEvent.keyDown(usernameInput, { key: 'Tab' });
|
||||
// Note: Actual tab behavior would need more complex setup
|
||||
// This is a simplified test for the presence of focusable elements
|
||||
expect(passwordInput).toBeInTheDocument();
|
||||
expect(regularButton).toBeInTheDocument();
|
||||
expect(oidcButton).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import { vi } from 'vitest';
|
||||
import OidcCallback from '../OidcCallback';
|
||||
import { AuthProvider } from '../../../contexts/AuthContext';
|
||||
import { api } from '../../../services/api';
|
||||
|
||||
// Mock the API
|
||||
vi.mock('../../../services/api', () => ({
|
||||
api: {
|
||||
get: vi.fn(),
|
||||
defaults: {
|
||||
headers: {
|
||||
common: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock useNavigate
|
||||
const mockNavigate = vi.fn();
|
||||
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate
|
||||
};
|
||||
});
|
||||
|
||||
// Mock localStorage
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: {
|
||||
setItem: vi.fn(),
|
||||
getItem: vi.fn(),
|
||||
removeItem: vi.fn()
|
||||
}
|
||||
});
|
||||
|
||||
// Mock window.location
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
href: ''
|
||||
},
|
||||
writable: true
|
||||
});
|
||||
|
||||
|
||||
// Mock AuthContext
|
||||
const mockAuthContextValue = {
|
||||
user: null,
|
||||
loading: false,
|
||||
login: vi.fn(),
|
||||
register: vi.fn(),
|
||||
logout: vi.fn()
|
||||
};
|
||||
|
||||
const MockAuthProvider = ({ children }: { children: React.ReactNode }) => (
|
||||
<AuthProvider>
|
||||
{children}
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
describe('OidcCallback', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.location.href = '';
|
||||
// Clear API mocks
|
||||
(api.get as any).mockClear();
|
||||
// Reset API mocks to default implementation
|
||||
(api.get as any).mockResolvedValue({ data: { token: 'default-token' } });
|
||||
|
||||
// Mock window.matchMedia
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation(query => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
const renderOidcCallback = (search = '') => {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[`/auth/oidc/callback${search}`]}>
|
||||
<MockAuthProvider>
|
||||
<Routes>
|
||||
<Route path="/auth/oidc/callback" element={<OidcCallback />} />
|
||||
<Route path="/login" element={<div>Login Page</div>} />
|
||||
</Routes>
|
||||
</MockAuthProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
};
|
||||
|
||||
it('shows loading state initially', async () => {
|
||||
// Mock the API call to delay so we can see the loading state
|
||||
(api.get as any).mockImplementation(() => new Promise(() => {})); // Never resolves
|
||||
|
||||
renderOidcCallback('?code=test-code&state=test-state');
|
||||
|
||||
expect(screen.getByText('Completing Authentication')).toBeInTheDocument();
|
||||
expect(screen.getByText('Please wait while we process your authentication...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles successful authentication', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
token: 'test-jwt-token',
|
||||
user: {
|
||||
id: '123',
|
||||
username: 'testuser',
|
||||
email: 'test@example.com'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
(api.get as any).mockResolvedValueOnce(mockResponse);
|
||||
|
||||
renderOidcCallback('?code=test-code&state=test-state');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.get).toHaveBeenCalledWith('/auth/oidc/callback?code=test-code&state=test-state');
|
||||
});
|
||||
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith('token', 'test-jwt-token');
|
||||
expect(window.location.href).toBe('/dashboard');
|
||||
});
|
||||
|
||||
it('handles authentication error from URL params', () => {
|
||||
renderOidcCallback('?error=access_denied&error_description=User+denied+access');
|
||||
|
||||
expect(screen.getByText('Authentication Error')).toBeInTheDocument();
|
||||
expect(screen.getByText('Authentication failed: access_denied')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles missing authorization code', () => {
|
||||
renderOidcCallback('');
|
||||
|
||||
expect(screen.getByText('Authentication Error')).toBeInTheDocument();
|
||||
expect(screen.getByText('No authorization code received')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles API error during callback', async () => {
|
||||
const error = {
|
||||
response: {
|
||||
data: {
|
||||
error: 'Invalid authorization code'
|
||||
}
|
||||
}
|
||||
};
|
||||
(api.get as any).mockRejectedValueOnce(error);
|
||||
|
||||
renderOidcCallback('?code=test-code&state=test-state');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Authentication Error')).toBeInTheDocument();
|
||||
expect(screen.getByText('Invalid authorization code')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('handles invalid response from server', async () => {
|
||||
(api.get as any).mockResolvedValueOnce({
|
||||
data: {
|
||||
// Missing token
|
||||
user: { id: '123' }
|
||||
}
|
||||
});
|
||||
|
||||
renderOidcCallback('?code=test-code&state=test-state');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Authentication Error')).toBeInTheDocument();
|
||||
expect(screen.getByText('Invalid response from authentication server')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('provides return to login button on error', async () => {
|
||||
(api.get as any).mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
renderOidcCallback('?code=test-code&state=test-state');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Return to Login')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Test clicking return to login
|
||||
const returnButton = screen.getByText('Return to Login');
|
||||
fireEvent.click(returnButton);
|
||||
|
||||
// Check if navigation to login page occurred by looking for login page content
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Login Page')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -19,4 +19,19 @@ vi.mock('axios', () => ({
|
|||
defaults: { headers: { common: {} } },
|
||||
})),
|
||||
},
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock window.matchMedia
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation(query => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(), // deprecated
|
||||
removeListener: vi.fn(), // deprecated
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
})
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
-- Add OIDC support to users table
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS oidc_subject VARCHAR(255);
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS oidc_issuer VARCHAR(255);
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS oidc_email VARCHAR(255);
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS auth_provider VARCHAR(50) DEFAULT 'local';
|
||||
|
||||
-- Create index for OIDC lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_users_oidc_subject_issuer ON users(oidc_subject, oidc_issuer);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_auth_provider ON users(auth_provider);
|
||||
|
||||
-- Make password_hash optional for OIDC users
|
||||
ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL;
|
||||
|
||||
-- Add constraint to ensure either password or OIDC fields are provided
|
||||
ALTER TABLE users ADD CONSTRAINT check_auth_method
|
||||
CHECK (
|
||||
(auth_provider = 'local' AND password_hash IS NOT NULL) OR
|
||||
(auth_provider = 'oidc' AND oidc_subject IS NOT NULL AND oidc_issuer IS NOT NULL)
|
||||
);
|
||||
|
|
@ -22,6 +22,13 @@ pub struct Config {
|
|||
// Performance
|
||||
pub memory_limit_mb: usize,
|
||||
pub cpu_priority: String,
|
||||
|
||||
// OIDC Configuration
|
||||
pub oidc_enabled: bool,
|
||||
pub oidc_client_id: Option<String>,
|
||||
pub oidc_client_secret: Option<String>,
|
||||
pub oidc_issuer_url: Option<String>,
|
||||
pub oidc_redirect_uri: Option<String>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
|
|
@ -335,6 +342,64 @@ impl Config {
|
|||
default_priority
|
||||
}
|
||||
},
|
||||
|
||||
// OIDC Configuration
|
||||
oidc_enabled: match env::var("OIDC_ENABLED") {
|
||||
Ok(val) => match val.to_lowercase().as_str() {
|
||||
"true" | "1" | "yes" | "on" => {
|
||||
println!("✅ OIDC_ENABLED: true (loaded from env)");
|
||||
true
|
||||
}
|
||||
_ => {
|
||||
println!("✅ OIDC_ENABLED: false (loaded from env)");
|
||||
false
|
||||
}
|
||||
},
|
||||
Err(_) => {
|
||||
println!("⚠️ OIDC_ENABLED: false (using default - env var not set)");
|
||||
false
|
||||
}
|
||||
},
|
||||
oidc_client_id: match env::var("OIDC_CLIENT_ID") {
|
||||
Ok(client_id) => {
|
||||
println!("✅ OIDC_CLIENT_ID: {} (loaded from env)", client_id);
|
||||
Some(client_id)
|
||||
}
|
||||
Err(_) => {
|
||||
println!("⚠️ OIDC_CLIENT_ID: Not set");
|
||||
None
|
||||
}
|
||||
},
|
||||
oidc_client_secret: match env::var("OIDC_CLIENT_SECRET") {
|
||||
Ok(secret) => {
|
||||
println!("✅ OIDC_CLIENT_SECRET: ***hidden*** (loaded from env, {} chars)", secret.len());
|
||||
Some(secret)
|
||||
}
|
||||
Err(_) => {
|
||||
println!("⚠️ OIDC_CLIENT_SECRET: Not set");
|
||||
None
|
||||
}
|
||||
},
|
||||
oidc_issuer_url: match env::var("OIDC_ISSUER_URL") {
|
||||
Ok(url) => {
|
||||
println!("✅ OIDC_ISSUER_URL: {} (loaded from env)", url);
|
||||
Some(url)
|
||||
}
|
||||
Err(_) => {
|
||||
println!("⚠️ OIDC_ISSUER_URL: Not set");
|
||||
None
|
||||
}
|
||||
},
|
||||
oidc_redirect_uri: match env::var("OIDC_REDIRECT_URI") {
|
||||
Ok(uri) => {
|
||||
println!("✅ OIDC_REDIRECT_URI: {} (loaded from env)", uri);
|
||||
Some(uri)
|
||||
}
|
||||
Err(_) => {
|
||||
println!("⚠️ OIDC_REDIRECT_URI: Not set");
|
||||
None
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
println!("\n🔍 CONFIGURATION VALIDATION:");
|
||||
|
|
@ -389,6 +454,25 @@ impl Config {
|
|||
println!("⚙️ INFO: High OCR concurrency ({}) may use significant CPU/memory", config.concurrent_ocr_jobs);
|
||||
}
|
||||
|
||||
// OIDC validation
|
||||
if config.oidc_enabled {
|
||||
println!("🔐 OIDC is enabled");
|
||||
if config.oidc_client_id.is_none() {
|
||||
println!("❌ OIDC_CLIENT_ID is required when OIDC is enabled");
|
||||
}
|
||||
if config.oidc_client_secret.is_none() {
|
||||
println!("❌ OIDC_CLIENT_SECRET is required when OIDC is enabled");
|
||||
}
|
||||
if config.oidc_issuer_url.is_none() {
|
||||
println!("❌ OIDC_ISSUER_URL is required when OIDC is enabled");
|
||||
}
|
||||
if config.oidc_redirect_uri.is_none() {
|
||||
println!("❌ OIDC_REDIRECT_URI is required when OIDC is enabled");
|
||||
}
|
||||
} else {
|
||||
println!("🔐 OIDC is disabled");
|
||||
}
|
||||
|
||||
println!("✅ Configuration validation completed successfully!\n");
|
||||
|
||||
Ok(config)
|
||||
|
|
|
|||
|
|
@ -88,23 +88,41 @@ impl Database {
|
|||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
// Create users table
|
||||
// Create users table with OIDC support
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
username VARCHAR(255) UNIQUE NOT NULL,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(10) DEFAULT 'user',
|
||||
password_hash VARCHAR(255),
|
||||
role VARCHAR(20) DEFAULT 'user',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
oidc_subject VARCHAR(255),
|
||||
oidc_issuer VARCHAR(255),
|
||||
oidc_email VARCHAR(255),
|
||||
auth_provider VARCHAR(50) DEFAULT 'local',
|
||||
CONSTRAINT check_auth_method CHECK (
|
||||
(auth_provider = 'local' AND password_hash IS NOT NULL) OR
|
||||
(auth_provider = 'oidc' AND oidc_subject IS NOT NULL AND oidc_issuer IS NOT NULL)
|
||||
),
|
||||
CONSTRAINT check_user_role CHECK (role IN ('admin', 'user'))
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
// Create indexes for OIDC
|
||||
sqlx::query(r#"CREATE INDEX IF NOT EXISTS idx_users_oidc_subject_issuer ON users(oidc_subject, oidc_issuer)"#)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(r#"CREATE INDEX IF NOT EXISTS idx_users_auth_provider ON users(auth_provider)"#)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
|
||||
// Create documents table
|
||||
sqlx::query(
|
||||
|
|
|
|||
118
src/db/users.rs
118
src/db/users.rs
|
|
@ -3,7 +3,7 @@ use chrono::Utc;
|
|||
use sqlx::Row;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::{CreateUser, User};
|
||||
use crate::models::{CreateUser, User, AuthProvider};
|
||||
use super::Database;
|
||||
|
||||
impl Database {
|
||||
|
|
@ -13,9 +13,10 @@ impl Database {
|
|||
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO users (username, email, password_hash, role, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, username, email, password_hash, role, created_at, updated_at
|
||||
INSERT INTO users (username, email, password_hash, role, created_at, updated_at, auth_provider)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, username, email, password_hash, role, created_at, updated_at,
|
||||
oidc_subject, oidc_issuer, oidc_email, auth_provider
|
||||
"#
|
||||
)
|
||||
.bind(&user.username)
|
||||
|
|
@ -24,6 +25,7 @@ impl Database {
|
|||
.bind(user.role.as_ref().unwrap_or(&crate::models::UserRole::User).to_string())
|
||||
.bind(now)
|
||||
.bind(now)
|
||||
.bind(AuthProvider::Local.to_string())
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
|
|
@ -35,12 +37,17 @@ impl Database {
|
|||
role: row.get::<String, _>("role").try_into().unwrap_or(crate::models::UserRole::User),
|
||||
created_at: row.get("created_at"),
|
||||
updated_at: row.get("updated_at"),
|
||||
oidc_subject: row.get("oidc_subject"),
|
||||
oidc_issuer: row.get("oidc_issuer"),
|
||||
oidc_email: row.get("oidc_email"),
|
||||
auth_provider: row.get::<String, _>("auth_provider").try_into().unwrap_or(AuthProvider::Local),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_user_by_username(&self, username: &str) -> Result<Option<User>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT id, username, email, password_hash, role, created_at, updated_at FROM users WHERE username = $1"
|
||||
"SELECT id, username, email, password_hash, role, created_at, updated_at,
|
||||
oidc_subject, oidc_issuer, oidc_email, auth_provider FROM users WHERE username = $1"
|
||||
)
|
||||
.bind(username)
|
||||
.fetch_optional(&self.pool)
|
||||
|
|
@ -55,6 +62,10 @@ impl Database {
|
|||
role: row.get::<String, _>("role").try_into().unwrap_or(crate::models::UserRole::User),
|
||||
created_at: row.get("created_at"),
|
||||
updated_at: row.get("updated_at"),
|
||||
oidc_subject: row.get("oidc_subject"),
|
||||
oidc_issuer: row.get("oidc_issuer"),
|
||||
oidc_email: row.get("oidc_email"),
|
||||
auth_provider: row.get::<String, _>("auth_provider").try_into().unwrap_or(AuthProvider::Local),
|
||||
})),
|
||||
None => Ok(None),
|
||||
}
|
||||
|
|
@ -62,7 +73,8 @@ impl Database {
|
|||
|
||||
pub async fn get_user_by_id(&self, id: Uuid) -> Result<Option<User>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT id, username, email, password_hash, role, created_at, updated_at FROM users WHERE id = $1"
|
||||
"SELECT id, username, email, password_hash, role, created_at, updated_at,
|
||||
oidc_subject, oidc_issuer, oidc_email, auth_provider FROM users WHERE id = $1"
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(&self.pool)
|
||||
|
|
@ -77,6 +89,10 @@ impl Database {
|
|||
role: row.get::<String, _>("role").try_into().unwrap_or(crate::models::UserRole::User),
|
||||
created_at: row.get("created_at"),
|
||||
updated_at: row.get("updated_at"),
|
||||
oidc_subject: row.get("oidc_subject"),
|
||||
oidc_issuer: row.get("oidc_issuer"),
|
||||
oidc_email: row.get("oidc_email"),
|
||||
auth_provider: row.get::<String, _>("auth_provider").try_into().unwrap_or(AuthProvider::Local),
|
||||
})),
|
||||
None => Ok(None),
|
||||
}
|
||||
|
|
@ -84,7 +100,8 @@ impl Database {
|
|||
|
||||
pub async fn get_all_users(&self) -> Result<Vec<User>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, username, email, password_hash, role, created_at, updated_at FROM users ORDER BY created_at DESC"
|
||||
"SELECT id, username, email, password_hash, role, created_at, updated_at,
|
||||
oidc_subject, oidc_issuer, oidc_email, auth_provider FROM users ORDER BY created_at DESC"
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
|
@ -99,6 +116,10 @@ impl Database {
|
|||
role: row.get::<String, _>("role").try_into().unwrap_or(crate::models::UserRole::User),
|
||||
created_at: row.get("created_at"),
|
||||
updated_at: row.get("updated_at"),
|
||||
oidc_subject: row.get("oidc_subject"),
|
||||
oidc_issuer: row.get("oidc_issuer"),
|
||||
oidc_email: row.get("oidc_email"),
|
||||
auth_provider: row.get::<String, _>("auth_provider").try_into().unwrap_or(AuthProvider::Local),
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
|
@ -111,7 +132,7 @@ impl Database {
|
|||
let username = username.unwrap_or(user.username);
|
||||
let email = email.unwrap_or(user.email);
|
||||
let password_hash = if let Some(pwd) = password {
|
||||
bcrypt::hash(&pwd, 12)?
|
||||
Some(bcrypt::hash(&pwd, 12)?)
|
||||
} else {
|
||||
user.password_hash
|
||||
};
|
||||
|
|
@ -120,7 +141,8 @@ impl Database {
|
|||
r#"
|
||||
UPDATE users SET username = $1, email = $2, password_hash = $3, updated_at = NOW()
|
||||
WHERE id = $4
|
||||
RETURNING id, username, email, password_hash, role, created_at, updated_at
|
||||
RETURNING id, username, email, password_hash, role, created_at, updated_at,
|
||||
oidc_subject, oidc_issuer, oidc_email, auth_provider
|
||||
"#
|
||||
)
|
||||
.bind(&username)
|
||||
|
|
@ -138,6 +160,10 @@ impl Database {
|
|||
role: row.get::<String, _>("role").try_into().unwrap_or(crate::models::UserRole::User),
|
||||
created_at: row.get("created_at"),
|
||||
updated_at: row.get("updated_at"),
|
||||
oidc_subject: row.get("oidc_subject"),
|
||||
oidc_issuer: row.get("oidc_issuer"),
|
||||
oidc_email: row.get("oidc_email"),
|
||||
auth_provider: row.get::<String, _>("auth_provider").try_into().unwrap_or(AuthProvider::Local),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -149,4 +175,78 @@ impl Database {
|
|||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_user_by_oidc_subject(&self, subject: &str, issuer: &str) -> Result<Option<User>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT id, username, email, password_hash, role, created_at, updated_at,
|
||||
oidc_subject, oidc_issuer, oidc_email, auth_provider
|
||||
FROM users WHERE oidc_subject = $1 AND oidc_issuer = $2"
|
||||
)
|
||||
.bind(subject)
|
||||
.bind(issuer)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
match row {
|
||||
Some(row) => Ok(Some(User {
|
||||
id: row.get("id"),
|
||||
username: row.get("username"),
|
||||
email: row.get("email"),
|
||||
password_hash: row.get("password_hash"),
|
||||
role: row.get::<String, _>("role").try_into().unwrap_or(crate::models::UserRole::User),
|
||||
created_at: row.get("created_at"),
|
||||
updated_at: row.get("updated_at"),
|
||||
oidc_subject: row.get("oidc_subject"),
|
||||
oidc_issuer: row.get("oidc_issuer"),
|
||||
oidc_email: row.get("oidc_email"),
|
||||
auth_provider: row.get::<String, _>("auth_provider").try_into().unwrap_or(AuthProvider::Local),
|
||||
})),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_oidc_user(
|
||||
&self,
|
||||
user: CreateUser,
|
||||
oidc_subject: &str,
|
||||
oidc_issuer: &str,
|
||||
oidc_email: &str,
|
||||
) -> Result<User> {
|
||||
let now = Utc::now();
|
||||
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO users (username, email, role, created_at, updated_at,
|
||||
oidc_subject, oidc_issuer, oidc_email, auth_provider)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id, username, email, password_hash, role, created_at, updated_at,
|
||||
oidc_subject, oidc_issuer, oidc_email, auth_provider
|
||||
"#
|
||||
)
|
||||
.bind(&user.username)
|
||||
.bind(&user.email)
|
||||
.bind(user.role.as_ref().unwrap_or(&crate::models::UserRole::User).to_string())
|
||||
.bind(now)
|
||||
.bind(now)
|
||||
.bind(oidc_subject)
|
||||
.bind(oidc_issuer)
|
||||
.bind(oidc_email)
|
||||
.bind(AuthProvider::Oidc.to_string())
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(User {
|
||||
id: row.get("id"),
|
||||
username: row.get("username"),
|
||||
email: row.get("email"),
|
||||
password_hash: row.get("password_hash"),
|
||||
role: row.get::<String, _>("role").try_into().unwrap_or(crate::models::UserRole::User),
|
||||
created_at: row.get("created_at"),
|
||||
updated_at: row.get("updated_at"),
|
||||
oidc_subject: row.get("oidc_subject"),
|
||||
oidc_issuer: row.get("oidc_issuer"),
|
||||
oidc_email: row.get("oidc_email"),
|
||||
auth_provider: row.get::<String, _>("auth_provider").try_into().unwrap_or(AuthProvider::Oidc),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ pub mod file_service;
|
|||
pub mod local_folder_service;
|
||||
pub mod models;
|
||||
pub mod ocr;
|
||||
pub mod oidc;
|
||||
pub mod ocr_api;
|
||||
pub mod ocr_enhanced;
|
||||
pub mod ocr_error;
|
||||
|
|
@ -38,6 +39,7 @@ use axum::{http::StatusCode, Json};
|
|||
use utoipa;
|
||||
use config::Config;
|
||||
use db::Database;
|
||||
use oidc::OidcClient;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
|
|
@ -46,6 +48,7 @@ pub struct AppState {
|
|||
pub webdav_scheduler: Option<std::sync::Arc<webdav_scheduler::WebDAVScheduler>>,
|
||||
pub source_scheduler: Option<std::sync::Arc<source_scheduler::SourceScheduler>>,
|
||||
pub queue_service: std::sync::Arc<ocr_queue::OcrQueueService>,
|
||||
pub oidc_client: Option<std::sync::Arc<OidcClient>>,
|
||||
}
|
||||
|
||||
/// Health check endpoint for monitoring
|
||||
|
|
|
|||
21
src/main.rs
21
src/main.rs
|
|
@ -290,6 +290,24 @@ async fn main() -> anyhow::Result<()> {
|
|||
concurrent_jobs
|
||||
));
|
||||
|
||||
// Initialize OIDC client if enabled
|
||||
let oidc_client = if config.oidc_enabled {
|
||||
match readur::oidc::OidcClient::new(&config).await {
|
||||
Ok(client) => {
|
||||
println!("✅ OIDC client initialized successfully");
|
||||
Some(Arc::new(client))
|
||||
}
|
||||
Err(e) => {
|
||||
error!("❌ Failed to initialize OIDC client: {}", e);
|
||||
println!("❌ OIDC authentication will be disabled");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("ℹ️ OIDC authentication is disabled");
|
||||
None
|
||||
};
|
||||
|
||||
// Create web-facing state with shared queue service
|
||||
let web_state = AppState {
|
||||
db: web_db,
|
||||
|
|
@ -297,6 +315,7 @@ async fn main() -> anyhow::Result<()> {
|
|||
webdav_scheduler: None, // Will be set after creating scheduler
|
||||
source_scheduler: None, // Will be set after creating scheduler
|
||||
queue_service: shared_queue_service.clone(),
|
||||
oidc_client: oidc_client.clone(),
|
||||
};
|
||||
let web_state = Arc::new(web_state);
|
||||
|
||||
|
|
@ -307,6 +326,7 @@ async fn main() -> anyhow::Result<()> {
|
|||
webdav_scheduler: None,
|
||||
source_scheduler: None,
|
||||
queue_service: shared_queue_service.clone(),
|
||||
oidc_client: oidc_client.clone(),
|
||||
};
|
||||
let background_state = Arc::new(background_state);
|
||||
|
||||
|
|
@ -384,6 +404,7 @@ async fn main() -> anyhow::Result<()> {
|
|||
webdav_scheduler: Some(webdav_scheduler.clone()),
|
||||
source_scheduler: Some(source_scheduler.clone()),
|
||||
queue_service: shared_queue_service.clone(),
|
||||
oidc_client: oidc_client.clone(),
|
||||
};
|
||||
let web_state = Arc::new(updated_web_state);
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,14 @@ pub enum UserRole {
|
|||
User,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, ToSchema)]
|
||||
pub enum AuthProvider {
|
||||
#[serde(rename = "local")]
|
||||
Local,
|
||||
#[serde(rename = "oidc")]
|
||||
Oidc,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for UserRole {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
|
|
@ -33,16 +41,42 @@ impl TryFrom<String> for UserRole {
|
|||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AuthProvider {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
AuthProvider::Local => write!(f, "local"),
|
||||
AuthProvider::Oidc => write!(f, "oidc"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<String> for AuthProvider {
|
||||
type Error = String;
|
||||
|
||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||
match value.as_str() {
|
||||
"local" => Ok(AuthProvider::Local),
|
||||
"oidc" => Ok(AuthProvider::Oidc),
|
||||
_ => Err(format!("Invalid auth provider: {}", value)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow, ToSchema)]
|
||||
pub struct User {
|
||||
pub id: Uuid,
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub password_hash: String,
|
||||
pub password_hash: Option<String>,
|
||||
#[sqlx(try_from = "String")]
|
||||
pub role: UserRole,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub oidc_subject: Option<String>,
|
||||
pub oidc_issuer: Option<String>,
|
||||
pub oidc_email: Option<String>,
|
||||
#[sqlx(try_from = "String")]
|
||||
pub auth_provider: AuthProvider,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,352 @@
|
|||
use anyhow::{anyhow, Result};
|
||||
use oauth2::{
|
||||
basic::BasicClient, reqwest::async_http_client, AuthUrl, AuthorizationCode, ClientId,
|
||||
ClientSecret, CsrfToken, PkceCodeChallenge, RedirectUrl, Scope, TokenResponse, TokenUrl,
|
||||
};
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct OidcDiscovery {
|
||||
pub authorization_endpoint: String,
|
||||
pub token_endpoint: String,
|
||||
pub userinfo_endpoint: String,
|
||||
pub issuer: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct OidcUserInfo {
|
||||
pub sub: String,
|
||||
pub email: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub preferred_username: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct OidcClient {
|
||||
oauth_client: BasicClient,
|
||||
discovery: OidcDiscovery,
|
||||
http_client: Client,
|
||||
}
|
||||
|
||||
impl OidcClient {
|
||||
pub async fn new(config: &Config) -> Result<Self> {
|
||||
let client_id = config
|
||||
.oidc_client_id
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("OIDC client ID not configured"))?;
|
||||
let client_secret = config
|
||||
.oidc_client_secret
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("OIDC client secret not configured"))?;
|
||||
let issuer_url = config
|
||||
.oidc_issuer_url
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("OIDC issuer URL not configured"))?;
|
||||
let redirect_uri = config
|
||||
.oidc_redirect_uri
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("OIDC redirect URI not configured"))?;
|
||||
|
||||
let http_client = Client::new();
|
||||
|
||||
// Discover OIDC endpoints
|
||||
let discovery = Self::discover_endpoints(&http_client, issuer_url).await?;
|
||||
|
||||
// Create OAuth2 client
|
||||
let oauth_client = BasicClient::new(
|
||||
ClientId::new(client_id.clone()),
|
||||
Some(ClientSecret::new(client_secret.clone())),
|
||||
AuthUrl::new(discovery.authorization_endpoint.clone())?,
|
||||
Some(TokenUrl::new(discovery.token_endpoint.clone())?),
|
||||
)
|
||||
.set_redirect_uri(RedirectUrl::new(redirect_uri.clone())?);
|
||||
|
||||
Ok(Self {
|
||||
oauth_client,
|
||||
discovery,
|
||||
http_client,
|
||||
})
|
||||
}
|
||||
|
||||
async fn discover_endpoints(client: &Client, issuer_url: &str) -> Result<OidcDiscovery> {
|
||||
let discovery_url = format!("{}/.well-known/openid-configuration", issuer_url.trim_end_matches('/'));
|
||||
|
||||
let response = client
|
||||
.get(&discovery_url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| anyhow!("Failed to fetch OIDC discovery document: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow!(
|
||||
"OIDC discovery failed with status: {}",
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
|
||||
let discovery: OidcDiscovery = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| anyhow!("Failed to parse OIDC discovery document: {}", e))?;
|
||||
|
||||
Ok(discovery)
|
||||
}
|
||||
|
||||
pub fn get_authorization_url(&self) -> (Url, CsrfToken) {
|
||||
let (pkce_challenge, _pkce_verifier) = PkceCodeChallenge::new_random_sha256();
|
||||
|
||||
self.oauth_client
|
||||
.authorize_url(CsrfToken::new_random)
|
||||
.add_scope(Scope::new("openid".to_string()))
|
||||
.add_scope(Scope::new("email".to_string()))
|
||||
.add_scope(Scope::new("profile".to_string()))
|
||||
.set_pkce_challenge(pkce_challenge)
|
||||
.url()
|
||||
}
|
||||
|
||||
pub async fn exchange_code(&self, code: &str) -> Result<String> {
|
||||
let token_result = self
|
||||
.oauth_client
|
||||
.exchange_code(AuthorizationCode::new(code.to_string()))
|
||||
.request_async(async_http_client)
|
||||
.await
|
||||
.map_err(|e| anyhow!("Failed to exchange authorization code: {}", e))?;
|
||||
|
||||
Ok(token_result.access_token().secret().clone())
|
||||
}
|
||||
|
||||
pub async fn get_user_info(&self, access_token: &str) -> Result<OidcUserInfo> {
|
||||
let response = self
|
||||
.http_client
|
||||
.get(&self.discovery.userinfo_endpoint)
|
||||
.bearer_auth(access_token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| anyhow!("Failed to fetch user info: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow!(
|
||||
"User info request failed with status: {}",
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
|
||||
let user_info: OidcUserInfo = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| anyhow!("Failed to parse user info: {}", e))?;
|
||||
|
||||
Ok(user_info)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct OidcAuthResponse {
|
||||
pub user_id: uuid::Uuid,
|
||||
pub username: String,
|
||||
pub email: Option<String>,
|
||||
pub is_new_user: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use wiremock::{matchers::{method, path}, Mock, MockServer, ResponseTemplate};
|
||||
|
||||
fn create_test_config_with_oidc(issuer_url: &str) -> Config {
|
||||
Config {
|
||||
database_url: "postgresql://test:test@localhost/test".to_string(),
|
||||
server_address: "127.0.0.1:8000".to_string(),
|
||||
jwt_secret: "test-secret".to_string(),
|
||||
upload_path: "./test-uploads".to_string(),
|
||||
watch_folder: "./test-watch".to_string(),
|
||||
allowed_file_types: vec!["pdf".to_string()],
|
||||
watch_interval_seconds: Some(30),
|
||||
file_stability_check_ms: Some(500),
|
||||
max_file_age_hours: None,
|
||||
ocr_language: "eng".to_string(),
|
||||
concurrent_ocr_jobs: 2,
|
||||
ocr_timeout_seconds: 60,
|
||||
max_file_size_mb: 10,
|
||||
memory_limit_mb: 256,
|
||||
cpu_priority: "normal".to_string(),
|
||||
oidc_enabled: true,
|
||||
oidc_client_id: Some("test-client-id".to_string()),
|
||||
oidc_client_secret: Some("test-client-secret".to_string()),
|
||||
oidc_issuer_url: Some(issuer_url.to_string()),
|
||||
oidc_redirect_uri: Some("http://localhost:8000/auth/oidc/callback".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_oidc_discovery() {
|
||||
let mock_server = MockServer::start().await;
|
||||
|
||||
let discovery_response = serde_json::json!({
|
||||
"issuer": mock_server.uri(),
|
||||
"authorization_endpoint": format!("{}/auth", mock_server.uri()),
|
||||
"token_endpoint": format!("{}/token", mock_server.uri()),
|
||||
"userinfo_endpoint": format!("{}/userinfo", mock_server.uri())
|
||||
});
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/.well-known/openid-configuration"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(discovery_response))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let config = create_test_config_with_oidc(&mock_server.uri());
|
||||
let oidc_client = OidcClient::new(&config).await;
|
||||
|
||||
assert!(oidc_client.is_ok());
|
||||
let client = oidc_client.unwrap();
|
||||
assert_eq!(client.discovery.issuer, mock_server.uri());
|
||||
assert_eq!(client.discovery.authorization_endpoint, format!("{}/auth", mock_server.uri()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_oidc_discovery_failure() {
|
||||
let mock_server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/.well-known/openid-configuration"))
|
||||
.respond_with(ResponseTemplate::new(404))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let config = create_test_config_with_oidc(&mock_server.uri());
|
||||
let oidc_client = OidcClient::new(&config).await;
|
||||
|
||||
assert!(oidc_client.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_authorization_url() {
|
||||
let mock_server = MockServer::start().await;
|
||||
|
||||
let discovery_response = serde_json::json!({
|
||||
"issuer": mock_server.uri(),
|
||||
"authorization_endpoint": format!("{}/auth", mock_server.uri()),
|
||||
"token_endpoint": format!("{}/token", mock_server.uri()),
|
||||
"userinfo_endpoint": format!("{}/userinfo", mock_server.uri())
|
||||
});
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/.well-known/openid-configuration"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(discovery_response))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let config = create_test_config_with_oidc(&mock_server.uri());
|
||||
let oidc_client = OidcClient::new(&config).await.unwrap();
|
||||
|
||||
let (auth_url, csrf_token) = oidc_client.get_authorization_url();
|
||||
|
||||
assert!(auth_url.to_string().contains("/auth"));
|
||||
assert!(auth_url.to_string().contains("client_id=test-client-id"));
|
||||
assert!(auth_url.to_string().contains("scope=openid+email+profile"));
|
||||
assert!(!csrf_token.secret().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_user_info() {
|
||||
let mock_server = MockServer::start().await;
|
||||
|
||||
let discovery_response = serde_json::json!({
|
||||
"issuer": mock_server.uri(),
|
||||
"authorization_endpoint": format!("{}/auth", mock_server.uri()),
|
||||
"token_endpoint": format!("{}/token", mock_server.uri()),
|
||||
"userinfo_endpoint": format!("{}/userinfo", mock_server.uri())
|
||||
});
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/.well-known/openid-configuration"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(discovery_response))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let user_info_response = serde_json::json!({
|
||||
"sub": "test-user-123",
|
||||
"email": "test@example.com",
|
||||
"name": "Test User",
|
||||
"preferred_username": "testuser"
|
||||
});
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/userinfo"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(user_info_response))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let config = create_test_config_with_oidc(&mock_server.uri());
|
||||
let oidc_client = OidcClient::new(&config).await.unwrap();
|
||||
|
||||
let user_info = oidc_client.get_user_info("test-access-token").await;
|
||||
|
||||
assert!(user_info.is_ok());
|
||||
let info = user_info.unwrap();
|
||||
assert_eq!(info.sub, "test-user-123");
|
||||
assert_eq!(info.email, Some("test@example.com".to_string()));
|
||||
assert_eq!(info.preferred_username, Some("testuser".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_user_info_unauthorized() {
|
||||
let mock_server = MockServer::start().await;
|
||||
|
||||
let discovery_response = serde_json::json!({
|
||||
"issuer": mock_server.uri(),
|
||||
"authorization_endpoint": format!("{}/auth", mock_server.uri()),
|
||||
"token_endpoint": format!("{}/token", mock_server.uri()),
|
||||
"userinfo_endpoint": format!("{}/userinfo", mock_server.uri())
|
||||
});
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/.well-known/openid-configuration"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(discovery_response))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/userinfo"))
|
||||
.respond_with(ResponseTemplate::new(401))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let config = create_test_config_with_oidc(&mock_server.uri());
|
||||
let oidc_client = OidcClient::new(&config).await.unwrap();
|
||||
|
||||
let user_info = oidc_client.get_user_info("invalid-access-token").await;
|
||||
|
||||
assert!(user_info.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_oidc_config_validation() {
|
||||
let mut config = create_test_config_with_oidc("https://test.example.com");
|
||||
|
||||
// Test missing client ID
|
||||
config.oidc_client_id = None;
|
||||
assert!(tokio_test::block_on(OidcClient::new(&config)).is_err());
|
||||
|
||||
// Test missing client secret
|
||||
config.oidc_client_id = Some("test-client-id".to_string());
|
||||
config.oidc_client_secret = None;
|
||||
assert!(tokio_test::block_on(OidcClient::new(&config)).is_err());
|
||||
|
||||
// Test missing issuer URL
|
||||
config.oidc_client_secret = Some("test-client-secret".to_string());
|
||||
config.oidc_issuer_url = None;
|
||||
assert!(tokio_test::block_on(OidcClient::new(&config)).is_err());
|
||||
|
||||
// Test missing redirect URI
|
||||
config.oidc_issuer_url = Some("https://test.example.com".to_string());
|
||||
config.oidc_redirect_uri = None;
|
||||
assert!(tokio_test::block_on(OidcClient::new(&config)).is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +1,16 @@
|
|||
use axum::{
|
||||
extract::State,
|
||||
extract::{Query, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Json, Response},
|
||||
response::{IntoResponse, Json, Response, Redirect},
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
auth::{create_jwt, AuthUser},
|
||||
models::{CreateUser, LoginRequest, LoginResponse, UserResponse},
|
||||
models::{CreateUser, LoginRequest, LoginResponse, UserResponse, UserRole},
|
||||
AppState,
|
||||
};
|
||||
|
||||
|
|
@ -18,8 +19,11 @@ pub fn router() -> Router<Arc<AppState>> {
|
|||
.route("/register", post(register))
|
||||
.route("/login", post(login))
|
||||
.route("/me", get(me))
|
||||
.route("/oidc/login", get(oidc_login))
|
||||
.route("/oidc/callback", get(oidc_callback))
|
||||
}
|
||||
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/auth/register",
|
||||
|
|
@ -87,7 +91,11 @@ async fn login(
|
|||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let is_valid = bcrypt::verify(&login_data.password, &user.password_hash)
|
||||
let password_hash = user.password_hash
|
||||
.as_ref()
|
||||
.ok_or(StatusCode::UNAUTHORIZED)?; // OIDC users don't have passwords
|
||||
|
||||
let is_valid = bcrypt::verify(&login_data.password, password_hash)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
if !is_valid {
|
||||
|
|
@ -118,4 +126,143 @@ async fn login(
|
|||
)]
|
||||
async fn me(auth_user: AuthUser) -> Json<UserResponse> {
|
||||
Json(auth_user.user.into())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OidcCallbackQuery {
|
||||
code: Option<String>,
|
||||
state: Option<String>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/auth/oidc/login",
|
||||
tag = "auth",
|
||||
responses(
|
||||
(status = 302, description = "Redirect to OIDC provider"),
|
||||
(status = 400, description = "OIDC not configured"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
async fn oidc_login(State(state): State<Arc<AppState>>) -> Result<Redirect, StatusCode> {
|
||||
let oidc_client = state
|
||||
.oidc_client
|
||||
.as_ref()
|
||||
.ok_or(StatusCode::BAD_REQUEST)?;
|
||||
|
||||
let (auth_url, _csrf_token) = oidc_client.get_authorization_url();
|
||||
|
||||
Ok(Redirect::to(auth_url.as_str()))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/auth/oidc/callback",
|
||||
tag = "auth",
|
||||
responses(
|
||||
(status = 200, description = "OIDC authentication successful", body = LoginResponse),
|
||||
(status = 400, description = "Bad request - missing or invalid parameters"),
|
||||
(status = 401, description = "Authentication failed"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
async fn oidc_callback(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(params): Query<OidcCallbackQuery>,
|
||||
) -> Result<Json<LoginResponse>, StatusCode> {
|
||||
tracing::info!("OIDC callback called with params: code={:?}, state={:?}, error={:?}",
|
||||
params.code, params.state, params.error);
|
||||
|
||||
if let Some(error) = params.error {
|
||||
tracing::error!("OIDC callback error: {}", error);
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
let code = params.code.ok_or(StatusCode::BAD_REQUEST)?;
|
||||
|
||||
let oidc_client = state
|
||||
.oidc_client
|
||||
.as_ref()
|
||||
.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
// Exchange authorization code for access token
|
||||
let access_token = oidc_client
|
||||
.exchange_code(&code)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to exchange code: {}", e);
|
||||
StatusCode::UNAUTHORIZED
|
||||
})?;
|
||||
|
||||
// Get user info from OIDC provider
|
||||
let user_info = oidc_client
|
||||
.get_user_info(&access_token)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to get user info: {}", e);
|
||||
StatusCode::UNAUTHORIZED
|
||||
})?;
|
||||
|
||||
// Find or create user in database
|
||||
let issuer_url = state.config.oidc_issuer_url.as_ref().unwrap();
|
||||
tracing::debug!("Looking up user by OIDC subject: {} and issuer: {}", user_info.sub, issuer_url);
|
||||
let user = match state.db.get_user_by_oidc_subject(&user_info.sub, issuer_url).await {
|
||||
Ok(Some(existing_user)) => {
|
||||
tracing::debug!("Found existing OIDC user: {}", existing_user.username);
|
||||
existing_user
|
||||
},
|
||||
Ok(None) => {
|
||||
tracing::debug!("Creating new OIDC user");
|
||||
// Create new user
|
||||
let username = user_info.preferred_username
|
||||
.or_else(|| user_info.email.clone())
|
||||
.unwrap_or_else(|| format!("oidc_user_{}", &user_info.sub[..8]));
|
||||
|
||||
let email = user_info.email.unwrap_or_else(|| format!("{}@oidc.local", username));
|
||||
|
||||
tracing::debug!("New user details - username: {}, email: {}", username, email);
|
||||
|
||||
let create_user = CreateUser {
|
||||
username,
|
||||
email: email.clone(),
|
||||
password: "".to_string(), // Not used for OIDC users
|
||||
role: Some(UserRole::User),
|
||||
};
|
||||
|
||||
let result = state.db.create_oidc_user(
|
||||
create_user,
|
||||
&user_info.sub,
|
||||
issuer_url,
|
||||
&email,
|
||||
).await;
|
||||
|
||||
match result {
|
||||
Ok(user) => {
|
||||
tracing::info!("Successfully created OIDC user: {}", user.username);
|
||||
user
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to create OIDC user: {} (full error: {:#})", e, e);
|
||||
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Database error during OIDC lookup: {}", e);
|
||||
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
};
|
||||
|
||||
// Create JWT token
|
||||
let token = create_jwt(&user, &state.config.jwt_secret)
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to create JWT token: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
Ok(Json(LoginResponse {
|
||||
token,
|
||||
user: user.into(),
|
||||
}))
|
||||
}
|
||||
|
|
@ -19,6 +19,8 @@ use testcontainers::{runners::AsyncRunner, ContainerAsync, ImageExt};
|
|||
use testcontainers_modules::postgres::Postgres;
|
||||
#[cfg(any(test, feature = "test-utils"))]
|
||||
use tower::util::ServiceExt;
|
||||
#[cfg(any(test, feature = "test-utils"))]
|
||||
use uuid;
|
||||
|
||||
/// Test image information with expected OCR content
|
||||
#[derive(Debug, Clone)]
|
||||
|
|
@ -187,6 +189,13 @@ pub async fn create_test_app() -> (Router, ContainerAsync<Postgres>) {
|
|||
// Performance
|
||||
memory_limit_mb: 256, // Lower for tests
|
||||
cpu_priority: "normal".to_string(),
|
||||
|
||||
// OIDC Configuration
|
||||
oidc_enabled: false,
|
||||
oidc_client_id: None,
|
||||
oidc_client_secret: None,
|
||||
oidc_issuer_url: None,
|
||||
oidc_redirect_uri: None,
|
||||
};
|
||||
|
||||
let queue_service = Arc::new(crate::ocr_queue::OcrQueueService::new(db.clone(), db.pool.clone(), 2));
|
||||
|
|
@ -197,6 +206,7 @@ pub async fn create_test_app() -> (Router, ContainerAsync<Postgres>) {
|
|||
webdav_scheduler: None,
|
||||
source_scheduler: None,
|
||||
queue_service,
|
||||
oidc_client: None,
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
|
|
@ -213,9 +223,14 @@ pub async fn create_test_app() -> (Router, ContainerAsync<Postgres>) {
|
|||
|
||||
#[cfg(any(test, feature = "test-utils"))]
|
||||
pub async fn create_test_user(app: &Router) -> UserResponse {
|
||||
// Generate random identifiers to avoid test interference
|
||||
let test_id = uuid::Uuid::new_v4().to_string()[..8].to_string();
|
||||
let test_username = format!("testuser_{}", test_id);
|
||||
let test_email = format!("test_{}@example.com", test_id);
|
||||
|
||||
let user_data = json!({
|
||||
"username": "testuser",
|
||||
"email": "test@example.com",
|
||||
"username": test_username,
|
||||
"email": test_email,
|
||||
"password": "password123"
|
||||
});
|
||||
|
||||
|
|
@ -240,9 +255,14 @@ pub async fn create_test_user(app: &Router) -> UserResponse {
|
|||
|
||||
#[cfg(any(test, feature = "test-utils"))]
|
||||
pub async fn create_admin_user(app: &Router) -> UserResponse {
|
||||
// Generate random identifiers to avoid test interference
|
||||
let test_id = uuid::Uuid::new_v4().to_string()[..8].to_string();
|
||||
let admin_username = format!("adminuser_{}", test_id);
|
||||
let admin_email = format!("admin_{}@example.com", test_id);
|
||||
|
||||
let admin_data = json!({
|
||||
"username": "adminuser",
|
||||
"email": "admin@example.com",
|
||||
"username": admin_username,
|
||||
"email": admin_email,
|
||||
"password": "adminpass123",
|
||||
"role": "admin"
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,10 +10,14 @@ mod tests {
|
|||
id: Uuid::new_v4(),
|
||||
username: "testuser".to_string(),
|
||||
email: "test@example.com".to_string(),
|
||||
password_hash: "hashed_password".to_string(),
|
||||
password_hash: Some("hashed_password".to_string()),
|
||||
role: crate::models::UserRole::User,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
oidc_subject: None,
|
||||
oidc_issuer: None,
|
||||
oidc_email: None,
|
||||
auth_provider: crate::models::AuthProvider::Local,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,330 @@
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::config::Config;
|
||||
use std::env;
|
||||
|
||||
fn create_base_config() -> Config {
|
||||
Config {
|
||||
database_url: "postgresql://test:test@localhost/test".to_string(),
|
||||
server_address: "127.0.0.1:8000".to_string(),
|
||||
jwt_secret: "test-secret".to_string(),
|
||||
upload_path: "./test-uploads".to_string(),
|
||||
watch_folder: "./test-watch".to_string(),
|
||||
allowed_file_types: vec!["pdf".to_string()],
|
||||
watch_interval_seconds: Some(30),
|
||||
file_stability_check_ms: Some(500),
|
||||
max_file_age_hours: None,
|
||||
ocr_language: "eng".to_string(),
|
||||
concurrent_ocr_jobs: 2,
|
||||
ocr_timeout_seconds: 60,
|
||||
max_file_size_mb: 10,
|
||||
memory_limit_mb: 256,
|
||||
cpu_priority: "normal".to_string(),
|
||||
oidc_enabled: false,
|
||||
oidc_client_id: None,
|
||||
oidc_client_secret: None,
|
||||
oidc_issuer_url: None,
|
||||
oidc_redirect_uri: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_oidc_disabled_by_default() {
|
||||
let config = create_base_config();
|
||||
assert!(!config.oidc_enabled);
|
||||
assert!(config.oidc_client_id.is_none());
|
||||
assert!(config.oidc_client_secret.is_none());
|
||||
assert!(config.oidc_issuer_url.is_none());
|
||||
assert!(config.oidc_redirect_uri.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_oidc_enabled_from_env() {
|
||||
// Clean up environment first to ensure test isolation
|
||||
env::remove_var("OIDC_ENABLED");
|
||||
env::remove_var("OIDC_CLIENT_ID");
|
||||
env::remove_var("OIDC_CLIENT_SECRET");
|
||||
env::remove_var("OIDC_ISSUER_URL");
|
||||
env::remove_var("OIDC_REDIRECT_URI");
|
||||
env::remove_var("DATABASE_URL");
|
||||
env::remove_var("JWT_SECRET");
|
||||
|
||||
env::set_var("OIDC_ENABLED", "true");
|
||||
env::set_var("OIDC_CLIENT_ID", "test-client-id");
|
||||
env::set_var("OIDC_CLIENT_SECRET", "test-client-secret");
|
||||
env::set_var("OIDC_ISSUER_URL", "https://provider.example.com");
|
||||
env::set_var("OIDC_REDIRECT_URI", "http://localhost:8000/auth/oidc/callback");
|
||||
env::set_var("DATABASE_URL", "postgresql://test:test@localhost/test");
|
||||
env::set_var("JWT_SECRET", "test-secret");
|
||||
|
||||
let config = Config::from_env().unwrap();
|
||||
|
||||
assert!(config.oidc_enabled);
|
||||
assert_eq!(config.oidc_client_id, Some("test-client-id".to_string()));
|
||||
assert_eq!(config.oidc_client_secret, Some("test-client-secret".to_string()));
|
||||
assert_eq!(config.oidc_issuer_url, Some("https://provider.example.com".to_string()));
|
||||
assert_eq!(config.oidc_redirect_uri, Some("http://localhost:8000/auth/oidc/callback".to_string()));
|
||||
|
||||
// Clean up
|
||||
env::remove_var("OIDC_ENABLED");
|
||||
env::remove_var("OIDC_CLIENT_ID");
|
||||
env::remove_var("OIDC_CLIENT_SECRET");
|
||||
env::remove_var("OIDC_ISSUER_URL");
|
||||
env::remove_var("OIDC_REDIRECT_URI");
|
||||
env::remove_var("DATABASE_URL");
|
||||
env::remove_var("JWT_SECRET");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_oidc_enabled_variations() {
|
||||
let test_cases = vec![
|
||||
("true", true),
|
||||
("TRUE", true),
|
||||
("1", true),
|
||||
("yes", true),
|
||||
("YES", true),
|
||||
("on", true),
|
||||
("ON", true),
|
||||
("false", false),
|
||||
("FALSE", false),
|
||||
("0", false),
|
||||
("no", false),
|
||||
("NO", false),
|
||||
("off", false),
|
||||
("OFF", false),
|
||||
("invalid", false),
|
||||
];
|
||||
|
||||
for (value, expected) in test_cases {
|
||||
// Clean up environment first for each iteration
|
||||
env::remove_var("OIDC_ENABLED");
|
||||
env::remove_var("OIDC_CLIENT_ID");
|
||||
env::remove_var("OIDC_CLIENT_SECRET");
|
||||
env::remove_var("OIDC_ISSUER_URL");
|
||||
env::remove_var("OIDC_REDIRECT_URI");
|
||||
env::remove_var("DATABASE_URL");
|
||||
env::remove_var("JWT_SECRET");
|
||||
|
||||
env::set_var("OIDC_ENABLED", value);
|
||||
env::set_var("DATABASE_URL", "postgresql://test:test@localhost/test");
|
||||
env::set_var("JWT_SECRET", "test-secret");
|
||||
|
||||
let config = Config::from_env().unwrap();
|
||||
assert_eq!(config.oidc_enabled, expected, "Failed for value: {}", value);
|
||||
|
||||
env::remove_var("OIDC_ENABLED");
|
||||
env::remove_var("DATABASE_URL");
|
||||
env::remove_var("JWT_SECRET");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_oidc_partial_config() {
|
||||
// Clean up environment first to ensure test isolation
|
||||
env::remove_var("OIDC_ENABLED");
|
||||
env::remove_var("OIDC_CLIENT_ID");
|
||||
env::remove_var("OIDC_CLIENT_SECRET");
|
||||
env::remove_var("OIDC_ISSUER_URL");
|
||||
env::remove_var("OIDC_REDIRECT_URI");
|
||||
env::remove_var("DATABASE_URL");
|
||||
env::remove_var("JWT_SECRET");
|
||||
|
||||
// Only set some OIDC vars
|
||||
env::set_var("OIDC_ENABLED", "true");
|
||||
env::set_var("OIDC_CLIENT_ID", "test-client-id");
|
||||
// Missing OIDC_CLIENT_SECRET, OIDC_ISSUER_URL, OIDC_REDIRECT_URI
|
||||
env::set_var("DATABASE_URL", "postgresql://test:test@localhost/test");
|
||||
env::set_var("JWT_SECRET", "test-secret");
|
||||
|
||||
let config = Config::from_env().unwrap();
|
||||
|
||||
assert!(config.oidc_enabled);
|
||||
assert_eq!(config.oidc_client_id, Some("test-client-id".to_string()));
|
||||
assert!(config.oidc_client_secret.is_none());
|
||||
assert!(config.oidc_issuer_url.is_none());
|
||||
assert!(config.oidc_redirect_uri.is_none());
|
||||
|
||||
// Clean up
|
||||
env::remove_var("OIDC_ENABLED");
|
||||
env::remove_var("OIDC_CLIENT_ID");
|
||||
env::remove_var("OIDC_CLIENT_SECRET");
|
||||
env::remove_var("OIDC_ISSUER_URL");
|
||||
env::remove_var("OIDC_REDIRECT_URI");
|
||||
env::remove_var("DATABASE_URL");
|
||||
env::remove_var("JWT_SECRET");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_oidc_disabled_with_config_present() {
|
||||
// Clean up environment first to ensure test isolation
|
||||
env::remove_var("OIDC_ENABLED");
|
||||
env::remove_var("OIDC_CLIENT_ID");
|
||||
env::remove_var("OIDC_CLIENT_SECRET");
|
||||
env::remove_var("OIDC_ISSUER_URL");
|
||||
env::remove_var("OIDC_REDIRECT_URI");
|
||||
env::remove_var("DATABASE_URL");
|
||||
env::remove_var("JWT_SECRET");
|
||||
|
||||
// OIDC disabled but config present
|
||||
env::set_var("OIDC_ENABLED", "false");
|
||||
env::set_var("OIDC_CLIENT_ID", "test-client-id");
|
||||
env::set_var("OIDC_CLIENT_SECRET", "test-client-secret");
|
||||
env::set_var("OIDC_ISSUER_URL", "https://provider.example.com");
|
||||
env::set_var("OIDC_REDIRECT_URI", "http://localhost:8000/auth/oidc/callback");
|
||||
env::set_var("DATABASE_URL", "postgresql://test:test@localhost/test");
|
||||
env::set_var("JWT_SECRET", "test-secret");
|
||||
|
||||
let config = Config::from_env().unwrap();
|
||||
|
||||
assert!(!config.oidc_enabled);
|
||||
assert_eq!(config.oidc_client_id, Some("test-client-id".to_string()));
|
||||
assert_eq!(config.oidc_client_secret, Some("test-client-secret".to_string()));
|
||||
assert_eq!(config.oidc_issuer_url, Some("https://provider.example.com".to_string()));
|
||||
assert_eq!(config.oidc_redirect_uri, Some("http://localhost:8000/auth/oidc/callback".to_string()));
|
||||
|
||||
// Clean up
|
||||
env::remove_var("OIDC_ENABLED");
|
||||
env::remove_var("OIDC_CLIENT_ID");
|
||||
env::remove_var("OIDC_CLIENT_SECRET");
|
||||
env::remove_var("OIDC_ISSUER_URL");
|
||||
env::remove_var("OIDC_REDIRECT_URI");
|
||||
env::remove_var("DATABASE_URL");
|
||||
env::remove_var("JWT_SECRET");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_oidc_empty_values() {
|
||||
// Clean up environment first to ensure test isolation
|
||||
env::remove_var("OIDC_ENABLED");
|
||||
env::remove_var("OIDC_CLIENT_ID");
|
||||
env::remove_var("OIDC_CLIENT_SECRET");
|
||||
env::remove_var("OIDC_ISSUER_URL");
|
||||
env::remove_var("OIDC_REDIRECT_URI");
|
||||
env::remove_var("DATABASE_URL");
|
||||
env::remove_var("JWT_SECRET");
|
||||
|
||||
env::set_var("OIDC_ENABLED", "true");
|
||||
env::set_var("OIDC_CLIENT_ID", "");
|
||||
env::set_var("OIDC_CLIENT_SECRET", "");
|
||||
env::set_var("OIDC_ISSUER_URL", "");
|
||||
env::set_var("OIDC_REDIRECT_URI", "");
|
||||
env::set_var("DATABASE_URL", "postgresql://test:test@localhost/test");
|
||||
env::set_var("JWT_SECRET", "test-secret");
|
||||
|
||||
let config = Config::from_env().unwrap();
|
||||
|
||||
assert!(config.oidc_enabled);
|
||||
// Empty string values should be converted to Some(empty_string)
|
||||
assert_eq!(config.oidc_client_id, Some("".to_string()));
|
||||
assert_eq!(config.oidc_client_secret, Some("".to_string()));
|
||||
assert_eq!(config.oidc_issuer_url, Some("".to_string()));
|
||||
assert_eq!(config.oidc_redirect_uri, Some("".to_string()));
|
||||
|
||||
// Clean up
|
||||
env::remove_var("OIDC_ENABLED");
|
||||
env::remove_var("OIDC_CLIENT_ID");
|
||||
env::remove_var("OIDC_CLIENT_SECRET");
|
||||
env::remove_var("OIDC_ISSUER_URL");
|
||||
env::remove_var("OIDC_REDIRECT_URI");
|
||||
env::remove_var("DATABASE_URL");
|
||||
env::remove_var("JWT_SECRET");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_oidc_config_validation_output() {
|
||||
// Clean up environment first to ensure test isolation
|
||||
env::remove_var("OIDC_ENABLED");
|
||||
env::remove_var("OIDC_CLIENT_ID");
|
||||
env::remove_var("OIDC_CLIENT_SECRET");
|
||||
env::remove_var("OIDC_ISSUER_URL");
|
||||
env::remove_var("OIDC_REDIRECT_URI");
|
||||
env::remove_var("DATABASE_URL");
|
||||
env::remove_var("JWT_SECRET");
|
||||
|
||||
// Test that validation warnings are properly formatted
|
||||
env::set_var("OIDC_ENABLED", "true");
|
||||
env::set_var("DATABASE_URL", "postgresql://test:test@localhost/test");
|
||||
env::set_var("JWT_SECRET", "test-secret");
|
||||
// Missing required OIDC fields
|
||||
|
||||
// This should succeed but show warnings
|
||||
let config = Config::from_env().unwrap();
|
||||
assert!(config.oidc_enabled);
|
||||
assert!(config.oidc_client_id.is_none());
|
||||
|
||||
// Clean up
|
||||
env::remove_var("OIDC_ENABLED");
|
||||
env::remove_var("OIDC_CLIENT_ID");
|
||||
env::remove_var("OIDC_CLIENT_SECRET");
|
||||
env::remove_var("OIDC_ISSUER_URL");
|
||||
env::remove_var("OIDC_REDIRECT_URI");
|
||||
env::remove_var("DATABASE_URL");
|
||||
env::remove_var("JWT_SECRET");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_oidc_complete_configuration() {
|
||||
// Clean up environment first to ensure test isolation
|
||||
env::remove_var("OIDC_ENABLED");
|
||||
env::remove_var("OIDC_CLIENT_ID");
|
||||
env::remove_var("OIDC_CLIENT_SECRET");
|
||||
env::remove_var("OIDC_ISSUER_URL");
|
||||
env::remove_var("OIDC_REDIRECT_URI");
|
||||
env::remove_var("DATABASE_URL");
|
||||
env::remove_var("JWT_SECRET");
|
||||
|
||||
env::set_var("OIDC_ENABLED", "true");
|
||||
env::set_var("OIDC_CLIENT_ID", "my-app-client-id");
|
||||
env::set_var("OIDC_CLIENT_SECRET", "super-secret-client-secret");
|
||||
env::set_var("OIDC_ISSUER_URL", "https://auth.example.com");
|
||||
env::set_var("OIDC_REDIRECT_URI", "https://myapp.com/auth/callback");
|
||||
env::set_var("DATABASE_URL", "postgresql://test:test@localhost/test");
|
||||
env::set_var("JWT_SECRET", "test-secret");
|
||||
|
||||
let config = Config::from_env().unwrap();
|
||||
|
||||
assert!(config.oidc_enabled);
|
||||
assert_eq!(config.oidc_client_id.unwrap(), "my-app-client-id");
|
||||
assert_eq!(config.oidc_client_secret.unwrap(), "super-secret-client-secret");
|
||||
assert_eq!(config.oidc_issuer_url.unwrap(), "https://auth.example.com");
|
||||
assert_eq!(config.oidc_redirect_uri.unwrap(), "https://myapp.com/auth/callback");
|
||||
|
||||
// Clean up
|
||||
env::remove_var("OIDC_ENABLED");
|
||||
env::remove_var("OIDC_CLIENT_ID");
|
||||
env::remove_var("OIDC_CLIENT_SECRET");
|
||||
env::remove_var("OIDC_ISSUER_URL");
|
||||
env::remove_var("OIDC_REDIRECT_URI");
|
||||
env::remove_var("DATABASE_URL");
|
||||
env::remove_var("JWT_SECRET");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_oidc_config_precedence() {
|
||||
// Clean up any existing env vars first
|
||||
env::remove_var("OIDC_ENABLED");
|
||||
env::remove_var("OIDC_CLIENT_ID");
|
||||
env::remove_var("OIDC_CLIENT_SECRET");
|
||||
env::remove_var("OIDC_ISSUER_URL");
|
||||
env::remove_var("OIDC_REDIRECT_URI");
|
||||
env::remove_var("DATABASE_URL");
|
||||
env::remove_var("JWT_SECRET");
|
||||
|
||||
// Test that environment variables take precedence
|
||||
env::set_var("OIDC_ENABLED", "true");
|
||||
env::set_var("OIDC_CLIENT_ID", "env-client-id");
|
||||
env::set_var("DATABASE_URL", "postgresql://test:test@localhost/test");
|
||||
env::set_var("JWT_SECRET", "test-secret");
|
||||
|
||||
let config = Config::from_env().unwrap();
|
||||
|
||||
assert!(config.oidc_enabled);
|
||||
assert_eq!(config.oidc_client_id.unwrap(), "env-client-id");
|
||||
|
||||
// Clean up
|
||||
env::remove_var("OIDC_ENABLED");
|
||||
env::remove_var("OIDC_CLIENT_ID");
|
||||
env::remove_var("DATABASE_URL");
|
||||
env::remove_var("JWT_SECRET");
|
||||
}
|
||||
}
|
||||
|
|
@ -64,8 +64,8 @@ mod tests {
|
|||
let user = result.unwrap();
|
||||
assert_eq!(user.username, "testuser_1");
|
||||
assert_eq!(user.email, "test@example.com");
|
||||
assert!(!user.password_hash.is_empty());
|
||||
assert_ne!(user.password_hash, "password123"); // Should be hashed
|
||||
assert!(user.password_hash.is_some());
|
||||
assert_ne!(user.password_hash.as_ref().unwrap(), "password123"); // Should be hashed
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#[cfg(test)]
|
||||
mod document_routes_deletion_tests {
|
||||
use crate::models::{UserRole, User, Document};
|
||||
use crate::models::{UserRole, User, Document, AuthProvider};
|
||||
use crate::routes::documents::{BulkDeleteRequest};
|
||||
use axum::http::StatusCode;
|
||||
use chrono::Utc;
|
||||
|
|
@ -28,10 +28,14 @@ mod document_routes_deletion_tests {
|
|||
id: Uuid::new_v4(),
|
||||
username: "testuser".to_string(),
|
||||
email: "test@example.com".to_string(),
|
||||
password_hash: "hashed_password".to_string(),
|
||||
password_hash: Some("hashed_password".to_string()),
|
||||
role,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
oidc_subject: None,
|
||||
oidc_issuer: None,
|
||||
oidc_email: None,
|
||||
auth_provider: AuthProvider::Local,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -316,7 +316,7 @@ mod tests {
|
|||
mod document_deletion_tests {
|
||||
use super::*;
|
||||
use crate::db::Database;
|
||||
use crate::models::{UserRole, User, Document};
|
||||
use crate::models::{UserRole, User, Document, AuthProvider};
|
||||
use chrono::Utc;
|
||||
use sqlx::PgPool;
|
||||
use std::env;
|
||||
|
|
@ -336,14 +336,18 @@ mod document_deletion_tests {
|
|||
id: user_id,
|
||||
username: format!("testuser_{}", user_id),
|
||||
email: format!("test_{}@example.com", user_id),
|
||||
password_hash: "hashed_password".to_string(),
|
||||
password_hash: Some("hashed_password".to_string()),
|
||||
role,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
oidc_subject: None,
|
||||
oidc_issuer: None,
|
||||
oidc_email: None,
|
||||
auth_provider: AuthProvider::Local,
|
||||
};
|
||||
|
||||
// Insert user into database
|
||||
sqlx::query("INSERT INTO users (id, username, email, password_hash, role, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7)")
|
||||
sqlx::query("INSERT INTO users (id, username, email, password_hash, role, created_at, updated_at, oidc_subject, oidc_issuer, oidc_email, auth_provider) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)")
|
||||
.bind(user.id)
|
||||
.bind(&user.username)
|
||||
.bind(&user.email)
|
||||
|
|
@ -351,6 +355,10 @@ mod document_deletion_tests {
|
|||
.bind(user.role.to_string())
|
||||
.bind(user.created_at)
|
||||
.bind(user.updated_at)
|
||||
.bind(&user.oidc_subject)
|
||||
.bind(&user.oidc_issuer)
|
||||
.bind(&user.oidc_email)
|
||||
.bind(user.auth_provider.to_string())
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("Failed to insert test user");
|
||||
|
|
@ -685,7 +693,7 @@ mod document_deletion_tests {
|
|||
mod rbac_deletion_tests {
|
||||
use super::*;
|
||||
use crate::db::Database;
|
||||
use crate::models::{UserRole, User, Document};
|
||||
use crate::models::{UserRole, User, Document, AuthProvider};
|
||||
use chrono::Utc;
|
||||
use sqlx::PgPool;
|
||||
use std::env;
|
||||
|
|
@ -705,13 +713,17 @@ mod rbac_deletion_tests {
|
|||
id: user_id,
|
||||
username: format!("testuser_{}", user_id),
|
||||
email: format!("test_{}@example.com", user_id),
|
||||
password_hash: "hashed_password".to_string(),
|
||||
password_hash: Some("hashed_password".to_string()),
|
||||
role,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
oidc_subject: None,
|
||||
oidc_issuer: None,
|
||||
oidc_email: None,
|
||||
auth_provider: AuthProvider::Local,
|
||||
};
|
||||
|
||||
sqlx::query("INSERT INTO users (id, username, email, password_hash, role, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7)")
|
||||
sqlx::query("INSERT INTO users (id, username, email, password_hash, role, created_at, updated_at, oidc_subject, oidc_issuer, oidc_email, auth_provider) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)")
|
||||
.bind(user.id)
|
||||
.bind(&user.username)
|
||||
.bind(&user.email)
|
||||
|
|
@ -719,6 +731,10 @@ mod rbac_deletion_tests {
|
|||
.bind(user.role.to_string())
|
||||
.bind(user.created_at)
|
||||
.bind(user.updated_at)
|
||||
.bind(&user.oidc_subject)
|
||||
.bind(&user.oidc_issuer)
|
||||
.bind(&user.oidc_email)
|
||||
.bind(user.auth_provider.to_string())
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("Failed to insert test user");
|
||||
|
|
@ -1104,7 +1120,7 @@ mod rbac_deletion_tests {
|
|||
mod deletion_error_handling_tests {
|
||||
use super::*;
|
||||
use crate::db::Database;
|
||||
use crate::models::{UserRole, User, Document};
|
||||
use crate::models::{UserRole, User, Document, AuthProvider};
|
||||
use chrono::Utc;
|
||||
use sqlx::PgPool;
|
||||
use std::env;
|
||||
|
|
@ -1124,13 +1140,17 @@ mod deletion_error_handling_tests {
|
|||
id: user_id,
|
||||
username: format!("testuser_{}", user_id),
|
||||
email: format!("test_{}@example.com", user_id),
|
||||
password_hash: "hashed_password".to_string(),
|
||||
password_hash: Some("hashed_password".to_string()),
|
||||
role,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
oidc_subject: None,
|
||||
oidc_issuer: None,
|
||||
oidc_email: None,
|
||||
auth_provider: AuthProvider::Local,
|
||||
};
|
||||
|
||||
sqlx::query("INSERT INTO users (id, username, email, password_hash, role, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7)")
|
||||
sqlx::query("INSERT INTO users (id, username, email, password_hash, role, created_at, updated_at, oidc_subject, oidc_issuer, oidc_email, auth_provider) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)")
|
||||
.bind(user.id)
|
||||
.bind(&user.username)
|
||||
.bind(&user.email)
|
||||
|
|
@ -1138,6 +1158,10 @@ mod deletion_error_handling_tests {
|
|||
.bind(user.role.to_string())
|
||||
.bind(user.created_at)
|
||||
.bind(user.updated_at)
|
||||
.bind(&user.oidc_subject)
|
||||
.bind(&user.oidc_issuer)
|
||||
.bind(&user.oidc_email)
|
||||
.bind(user.auth_provider.to_string())
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("Failed to insert test user");
|
||||
|
|
|
|||
|
|
@ -39,6 +39,13 @@ pub async fn create_test_app() -> (Router, ContainerAsync<Postgres>) {
|
|||
// Performance
|
||||
memory_limit_mb: 256, // Lower for tests
|
||||
cpu_priority: "normal".to_string(),
|
||||
|
||||
// OIDC Configuration (disabled for tests)
|
||||
oidc_enabled: false,
|
||||
oidc_client_id: None,
|
||||
oidc_client_secret: None,
|
||||
oidc_issuer_url: None,
|
||||
oidc_redirect_uri: None,
|
||||
};
|
||||
|
||||
let queue_service = Arc::new(crate::ocr_queue::OcrQueueService::new(db.clone(), db.pool.clone(), 2));
|
||||
|
|
@ -49,6 +56,7 @@ pub async fn create_test_app() -> (Router, ContainerAsync<Postgres>) {
|
|||
webdav_scheduler: None,
|
||||
source_scheduler: None,
|
||||
queue_service,
|
||||
oidc_client: None,
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ mod tests {
|
|||
is_file_ignored, count_ignored_files, bulk_delete_ignored_files,
|
||||
create_ignored_file_from_document
|
||||
};
|
||||
use crate::models::{CreateIgnoredFile, IgnoredFilesQuery, User, UserRole, Document};
|
||||
use crate::models::{CreateIgnoredFile, IgnoredFilesQuery, User, UserRole, Document, AuthProvider};
|
||||
use uuid::Uuid;
|
||||
use chrono::Utc;
|
||||
use sqlx::PgPool;
|
||||
|
|
@ -30,13 +30,17 @@ mod tests {
|
|||
id: user_id,
|
||||
username: format!("testuser_{}", user_id),
|
||||
email: format!("test_{}@example.com", user_id),
|
||||
password_hash: "hashed_password".to_string(),
|
||||
password_hash: Some("hashed_password".to_string()),
|
||||
role: UserRole::User,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
oidc_subject: None,
|
||||
oidc_issuer: None,
|
||||
oidc_email: None,
|
||||
auth_provider: AuthProvider::Local,
|
||||
};
|
||||
|
||||
sqlx::query("INSERT INTO users (id, username, email, password_hash, role, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7)")
|
||||
sqlx::query("INSERT INTO users (id, username, email, password_hash, role, created_at, updated_at, oidc_subject, oidc_issuer, oidc_email, auth_provider) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)")
|
||||
.bind(user.id)
|
||||
.bind(&user.username)
|
||||
.bind(&user.email)
|
||||
|
|
@ -44,6 +48,10 @@ mod tests {
|
|||
.bind(user.role.to_string())
|
||||
.bind(user.created_at)
|
||||
.bind(user.updated_at)
|
||||
.bind(&user.oidc_subject)
|
||||
.bind(&user.oidc_issuer)
|
||||
.bind(&user.oidc_email)
|
||||
.bind(user.auth_provider.to_string())
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("Failed to insert test user");
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
mod helpers;
|
||||
mod auth_tests;
|
||||
mod config_oidc_tests;
|
||||
mod db_tests;
|
||||
mod documents_tests;
|
||||
mod document_routes_tests;
|
||||
|
|
@ -7,6 +8,7 @@ mod file_service_tests;
|
|||
mod ignored_files_tests;
|
||||
mod labels_tests;
|
||||
mod ocr_tests;
|
||||
mod oidc_tests;
|
||||
mod enhanced_search_tests;
|
||||
mod settings_tests;
|
||||
mod users_tests;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,423 @@
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::models::{AuthProvider, CreateUser, UserRole};
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::json;
|
||||
use tower::util::ServiceExt;
|
||||
use wiremock::{matchers::{method, path, query_param, header}, Mock, MockServer, ResponseTemplate};
|
||||
use std::sync::Arc;
|
||||
use crate::{AppState, oidc::OidcClient};
|
||||
use uuid;
|
||||
|
||||
async fn create_test_app_simple() -> (axum::Router, ()) {
|
||||
// Use TEST_DATABASE_URL directly, no containers
|
||||
let database_url = std::env::var("TEST_DATABASE_URL")
|
||||
.or_else(|_| std::env::var("DATABASE_URL"))
|
||||
.unwrap_or_else(|_| "postgresql://readur:readur@localhost:5432/readur".to_string());
|
||||
|
||||
let config = crate::config::Config {
|
||||
database_url: database_url.clone(),
|
||||
server_address: "127.0.0.1:0".to_string(),
|
||||
jwt_secret: "test-secret".to_string(),
|
||||
upload_path: "./test-uploads".to_string(),
|
||||
watch_folder: "./test-watch".to_string(),
|
||||
allowed_file_types: vec!["pdf".to_string()],
|
||||
watch_interval_seconds: Some(30),
|
||||
file_stability_check_ms: Some(500),
|
||||
max_file_age_hours: None,
|
||||
ocr_language: "eng".to_string(),
|
||||
concurrent_ocr_jobs: 2,
|
||||
ocr_timeout_seconds: 60,
|
||||
max_file_size_mb: 10,
|
||||
memory_limit_mb: 256,
|
||||
cpu_priority: "normal".to_string(),
|
||||
oidc_enabled: false,
|
||||
oidc_client_id: None,
|
||||
oidc_client_secret: None,
|
||||
oidc_issuer_url: None,
|
||||
oidc_redirect_uri: None,
|
||||
};
|
||||
|
||||
let db = crate::db::Database::new(&config.database_url).await.unwrap();
|
||||
|
||||
// Retry migration up to 3 times to handle concurrent test execution
|
||||
for attempt in 1..=3 {
|
||||
match db.migrate().await {
|
||||
Ok(_) => break,
|
||||
Err(e) if attempt < 3 && e.to_string().contains("tuple concurrently updated") => {
|
||||
// Wait a bit and retry
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100 * attempt)).await;
|
||||
continue;
|
||||
}
|
||||
Err(e) => panic!("Migration failed after {} attempts: {}", attempt, e),
|
||||
}
|
||||
}
|
||||
|
||||
let app = axum::Router::new()
|
||||
.nest("/api/auth", crate::routes::auth::router())
|
||||
.with_state(Arc::new(AppState {
|
||||
db: db.clone(),
|
||||
config,
|
||||
webdav_scheduler: None,
|
||||
source_scheduler: None,
|
||||
queue_service: Arc::new(crate::ocr_queue::OcrQueueService::new(
|
||||
db.clone(),
|
||||
db.pool.clone(),
|
||||
2
|
||||
)),
|
||||
oidc_client: None,
|
||||
}));
|
||||
|
||||
(app, ())
|
||||
}
|
||||
|
||||
async fn create_test_app_with_oidc() -> (axum::Router, MockServer) {
|
||||
let mock_server = MockServer::start().await;
|
||||
|
||||
// Mock OIDC discovery endpoint
|
||||
let discovery_response = json!({
|
||||
"issuer": mock_server.uri(),
|
||||
"authorization_endpoint": format!("{}/auth", mock_server.uri()),
|
||||
"token_endpoint": format!("{}/token", mock_server.uri()),
|
||||
"userinfo_endpoint": format!("{}/userinfo", mock_server.uri())
|
||||
});
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/.well-known/openid-configuration"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(discovery_response))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
// Use TEST_DATABASE_URL directly, no containers
|
||||
let database_url = std::env::var("TEST_DATABASE_URL")
|
||||
.or_else(|_| std::env::var("DATABASE_URL"))
|
||||
.unwrap_or_else(|_| "postgresql://readur:readur@localhost:5432/readur".to_string());
|
||||
|
||||
// Update the app state to include OIDC client
|
||||
let config = crate::config::Config {
|
||||
database_url: database_url.clone(),
|
||||
server_address: "127.0.0.1:0".to_string(),
|
||||
jwt_secret: "test-secret".to_string(),
|
||||
upload_path: "./test-uploads".to_string(),
|
||||
watch_folder: "./test-watch".to_string(),
|
||||
allowed_file_types: vec!["pdf".to_string()],
|
||||
watch_interval_seconds: Some(30),
|
||||
file_stability_check_ms: Some(500),
|
||||
max_file_age_hours: None,
|
||||
ocr_language: "eng".to_string(),
|
||||
concurrent_ocr_jobs: 2,
|
||||
ocr_timeout_seconds: 60,
|
||||
max_file_size_mb: 10,
|
||||
memory_limit_mb: 256,
|
||||
cpu_priority: "normal".to_string(),
|
||||
oidc_enabled: true,
|
||||
oidc_client_id: Some("test-client-id".to_string()),
|
||||
oidc_client_secret: Some("test-client-secret".to_string()),
|
||||
oidc_issuer_url: Some(mock_server.uri()),
|
||||
oidc_redirect_uri: Some("http://localhost:8000/auth/oidc/callback".to_string()),
|
||||
};
|
||||
|
||||
let oidc_client = match OidcClient::new(&config).await {
|
||||
Ok(client) => Some(Arc::new(client)),
|
||||
Err(e) => {
|
||||
panic!("OIDC client creation failed: {}", e);
|
||||
}
|
||||
};
|
||||
|
||||
// Connect to the database and run migrations with retry logic for concurrency
|
||||
let db = crate::db::Database::new(&config.database_url).await.unwrap();
|
||||
|
||||
// Retry migration up to 3 times to handle concurrent test execution
|
||||
for attempt in 1..=3 {
|
||||
match db.migrate().await {
|
||||
Ok(_) => break,
|
||||
Err(e) if attempt < 3 && e.to_string().contains("tuple concurrently updated") => {
|
||||
// Wait a bit and retry
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100 * attempt)).await;
|
||||
continue;
|
||||
}
|
||||
Err(e) => panic!("Migration failed after {} attempts: {}", attempt, e),
|
||||
}
|
||||
}
|
||||
|
||||
// Create app with OIDC configuration
|
||||
let app = axum::Router::new()
|
||||
.nest("/api/auth", crate::routes::auth::router())
|
||||
.with_state(Arc::new(AppState {
|
||||
db: db.clone(),
|
||||
config,
|
||||
webdav_scheduler: None,
|
||||
source_scheduler: None,
|
||||
queue_service: Arc::new(crate::ocr_queue::OcrQueueService::new(
|
||||
db.clone(),
|
||||
db.pool.clone(),
|
||||
2
|
||||
)),
|
||||
oidc_client,
|
||||
}));
|
||||
|
||||
(app, mock_server)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_oidc_login_redirect() {
|
||||
let (app, _mock_server) = create_test_app_with_oidc().await;
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/auth/oidc/login")
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::SEE_OTHER);
|
||||
|
||||
let location = response.headers().get("location").unwrap().to_str().unwrap();
|
||||
assert!(location.contains("/auth"));
|
||||
assert!(location.contains("client_id=test-client-id"));
|
||||
assert!(location.contains("scope=openid"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_oidc_login_disabled() {
|
||||
let (app, _container) = create_test_app_simple().await; // Regular app without OIDC
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/auth/oidc/login")
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_oidc_callback_missing_code() {
|
||||
let (app, _mock_server) = create_test_app_with_oidc().await;
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/auth/oidc/callback")
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_oidc_callback_with_error() {
|
||||
let (app, _mock_server) = create_test_app_with_oidc().await;
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/auth/oidc/callback?error=access_denied")
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_oidc_callback_success_new_user() {
|
||||
let (app, mock_server) = create_test_app_with_oidc().await;
|
||||
|
||||
// Generate random identifiers to avoid test interference
|
||||
let test_id = uuid::Uuid::new_v4().to_string()[..8].to_string();
|
||||
let test_username = format!("oidcuser_{}", test_id);
|
||||
let test_email = format!("oidc_{}@example.com", test_id);
|
||||
let test_subject = format!("oidc-user-{}", test_id);
|
||||
|
||||
// Clean up any existing test user to ensure test isolation
|
||||
let database_url = std::env::var("TEST_DATABASE_URL")
|
||||
.or_else(|_| std::env::var("DATABASE_URL"))
|
||||
.unwrap_or_else(|_| "postgresql://readur:readur@localhost:5432/readur".to_string());
|
||||
let db = crate::db::Database::new(&database_url).await.unwrap();
|
||||
|
||||
// Delete any existing user with the test username or OIDC subject
|
||||
let _ = sqlx::query("DELETE FROM users WHERE username = $1 OR oidc_subject = $2")
|
||||
.bind(&test_username)
|
||||
.bind(&test_subject)
|
||||
.execute(&db.pool)
|
||||
.await;
|
||||
|
||||
|
||||
// Mock token exchange
|
||||
let token_response = json!({
|
||||
"access_token": "test-access-token",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600
|
||||
});
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/token"))
|
||||
.and(header("content-type", "application/x-www-form-urlencoded"))
|
||||
.respond_with(ResponseTemplate::new(200)
|
||||
.set_body_json(token_response)
|
||||
.insert_header("content-type", "application/json"))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
// Mock user info
|
||||
let user_info_response = json!({
|
||||
"sub": test_subject,
|
||||
"email": test_email,
|
||||
"name": "OIDC User",
|
||||
"preferred_username": test_username
|
||||
});
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/userinfo"))
|
||||
.respond_with(ResponseTemplate::new(200)
|
||||
.set_body_json(user_info_response)
|
||||
.insert_header("content-type", "application/json"))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
// Add a small delay to make sure everything is set up
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/auth/oidc/callback?code=test-auth-code&state=test-state")
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let status = response.status();
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
if status != StatusCode::OK {
|
||||
let error_text = String::from_utf8_lossy(&body);
|
||||
eprintln!("Response status: {}", status);
|
||||
eprintln!("Response body: {}", error_text);
|
||||
|
||||
// Also check if we made the expected API calls to the mock server
|
||||
eprintln!("Mock server received calls:");
|
||||
let received_requests = mock_server.received_requests().await.unwrap();
|
||||
for req in received_requests {
|
||||
eprintln!(" {} {} - {}", req.method, req.url.path(), String::from_utf8_lossy(&req.body));
|
||||
}
|
||||
|
||||
// Try to parse as JSON to see if there's a more detailed error message
|
||||
if let Ok(error_json) = serde_json::from_slice::<serde_json::Value>(&body) {
|
||||
eprintln!("Error JSON: {:#}", error_json);
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
|
||||
let login_response: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
|
||||
assert!(login_response["token"].is_string());
|
||||
assert_eq!(login_response["user"]["username"], test_username);
|
||||
assert_eq!(login_response["user"]["email"], test_email);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_oidc_callback_invalid_token() {
|
||||
let (app, mock_server) = create_test_app_with_oidc().await;
|
||||
|
||||
// Mock failed token exchange
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/token"))
|
||||
.respond_with(ResponseTemplate::new(400).set_body_json(json!({
|
||||
"error": "invalid_grant"
|
||||
})))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/auth/oidc/callback?code=invalid-auth-code")
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_oidc_callback_invalid_user_info() {
|
||||
let (app, mock_server) = create_test_app_with_oidc().await;
|
||||
|
||||
// Generate random identifiers to avoid test interference
|
||||
let test_id = uuid::Uuid::new_v4().to_string()[..8].to_string();
|
||||
let test_username = format!("oidcuser_{}", test_id);
|
||||
let test_subject = format!("oidc-user-{}", test_id);
|
||||
|
||||
// Clean up any existing test user to ensure test isolation
|
||||
let database_url = std::env::var("TEST_DATABASE_URL")
|
||||
.or_else(|_| std::env::var("DATABASE_URL"))
|
||||
.unwrap_or_else(|_| "postgresql://readur:readur@localhost:5432/readur".to_string());
|
||||
let db = crate::db::Database::new(&database_url).await.unwrap();
|
||||
|
||||
// Delete any existing user that might conflict
|
||||
let _ = sqlx::query("DELETE FROM users WHERE username = $1 OR oidc_subject = $2")
|
||||
.bind(&test_username)
|
||||
.bind(&test_subject)
|
||||
.execute(&db.pool)
|
||||
.await;
|
||||
|
||||
// Mock successful token exchange
|
||||
let token_response = json!({
|
||||
"access_token": "test-access-token",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600
|
||||
});
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/token"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(token_response))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
// Mock failed user info
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/userinfo"))
|
||||
.respond_with(ResponseTemplate::new(401))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/auth/oidc/callback?code=test-auth-code")
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::models::{CreateUser, UpdateUser, UserResponse};
|
||||
use crate::models::{CreateUser, UpdateUser, UserResponse, AuthProvider, UserRole};
|
||||
use super::super::helpers::{create_test_app, create_test_user, create_admin_user, login_user};
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::json;
|
||||
use tower::util::ServiceExt;
|
||||
use uuid;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_users() {
|
||||
|
|
@ -298,4 +299,179 @@ mod tests {
|
|||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// OIDC Database Tests
|
||||
#[tokio::test]
|
||||
async fn test_create_oidc_user() {
|
||||
let (_app, container) = create_test_app().await;
|
||||
let port = container.get_host_port_ipv4(5432).await.unwrap();
|
||||
let database_url = format!("postgresql://test:test@localhost:{}/test", port);
|
||||
let db = crate::db::Database::new(&database_url).await.unwrap();
|
||||
|
||||
// Generate random identifiers to avoid test interference
|
||||
let test_id = uuid::Uuid::new_v4().to_string()[..8].to_string();
|
||||
let test_username = format!("oidcuser_{}", test_id);
|
||||
let test_email = format!("oidc_{}@example.com", test_id);
|
||||
let test_subject = format!("oidc-subject-{}", test_id);
|
||||
|
||||
let create_user = CreateUser {
|
||||
username: test_username.clone(),
|
||||
email: test_email.clone(),
|
||||
password: "".to_string(), // Not used for OIDC
|
||||
role: Some(UserRole::User),
|
||||
};
|
||||
|
||||
let user = db.create_oidc_user(
|
||||
create_user,
|
||||
&test_subject,
|
||||
"https://provider.example.com",
|
||||
&test_email,
|
||||
).await.unwrap();
|
||||
|
||||
assert_eq!(user.username, test_username);
|
||||
assert_eq!(user.email, test_email);
|
||||
assert_eq!(user.oidc_subject, Some(test_subject));
|
||||
assert_eq!(user.oidc_issuer, Some("https://provider.example.com".to_string()));
|
||||
assert_eq!(user.oidc_email, Some(test_email.clone()));
|
||||
assert_eq!(user.auth_provider, AuthProvider::Oidc);
|
||||
assert!(user.password_hash.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_user_by_oidc_subject() {
|
||||
let (_app, container) = create_test_app().await;
|
||||
let port = container.get_host_port_ipv4(5432).await.unwrap();
|
||||
let database_url = format!("postgresql://test:test@localhost:{}/test", port);
|
||||
let db = crate::db::Database::new(&database_url).await.unwrap();
|
||||
|
||||
// Generate random identifiers to avoid test interference
|
||||
let test_id = uuid::Uuid::new_v4().to_string()[..8].to_string();
|
||||
let test_username = format!("oidcuser_{}", test_id);
|
||||
let test_email = format!("oidc_{}@example.com", test_id);
|
||||
let test_subject = format!("oidc-subject-{}", test_id);
|
||||
|
||||
// Create OIDC user
|
||||
let create_user = CreateUser {
|
||||
username: test_username,
|
||||
email: test_email.clone(),
|
||||
password: "".to_string(),
|
||||
role: Some(UserRole::User),
|
||||
};
|
||||
|
||||
let created_user = db.create_oidc_user(
|
||||
create_user,
|
||||
&test_subject,
|
||||
"https://provider.example.com",
|
||||
&test_email,
|
||||
).await.unwrap();
|
||||
|
||||
// Retrieve by OIDC subject
|
||||
let found_user = db.get_user_by_oidc_subject(
|
||||
&test_subject,
|
||||
"https://provider.example.com"
|
||||
).await.unwrap();
|
||||
|
||||
assert!(found_user.is_some());
|
||||
let user = found_user.unwrap();
|
||||
assert_eq!(user.id, created_user.id);
|
||||
assert_eq!(user.oidc_subject, Some(test_subject));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_user_by_oidc_subject_not_found() {
|
||||
let (_app, container) = create_test_app().await;
|
||||
let port = container.get_host_port_ipv4(5432).await.unwrap();
|
||||
let database_url = format!("postgresql://test:test@localhost:{}/test", port);
|
||||
let db = crate::db::Database::new(&database_url).await.unwrap();
|
||||
|
||||
// Generate random subject that definitely doesn't exist
|
||||
let test_id = uuid::Uuid::new_v4().to_string();
|
||||
let nonexistent_subject = format!("nonexistent-subject-{}", test_id);
|
||||
|
||||
let found_user = db.get_user_by_oidc_subject(
|
||||
&nonexistent_subject,
|
||||
"https://provider.example.com"
|
||||
).await.unwrap();
|
||||
|
||||
assert!(found_user.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_oidc_user_different_issuer() {
|
||||
let (_app, container) = create_test_app().await;
|
||||
let port = container.get_host_port_ipv4(5432).await.unwrap();
|
||||
let database_url = format!("postgresql://test:test@localhost:{}/test", port);
|
||||
let db = crate::db::Database::new(&database_url).await.unwrap();
|
||||
|
||||
// Generate random identifiers to avoid test interference
|
||||
let test_id = uuid::Uuid::new_v4().to_string()[..8].to_string();
|
||||
let test_username = format!("oidcuser_{}", test_id);
|
||||
let test_email = format!("oidc_{}@example.com", test_id);
|
||||
let test_subject = format!("same-subject-{}", test_id);
|
||||
|
||||
// Create OIDC user with one issuer
|
||||
let create_user = CreateUser {
|
||||
username: test_username,
|
||||
email: test_email.clone(),
|
||||
password: "".to_string(),
|
||||
role: Some(UserRole::User),
|
||||
};
|
||||
|
||||
db.create_oidc_user(
|
||||
create_user,
|
||||
&test_subject,
|
||||
"https://provider1.example.com",
|
||||
&test_email,
|
||||
).await.unwrap();
|
||||
|
||||
// Try to find with different issuer (should not find)
|
||||
let found_user = db.get_user_by_oidc_subject(
|
||||
&test_subject,
|
||||
"https://provider2.example.com"
|
||||
).await.unwrap();
|
||||
|
||||
assert!(found_user.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_local_user_login_works() {
|
||||
let (app, container) = create_test_app().await;
|
||||
let port = container.get_host_port_ipv4(5432).await.unwrap();
|
||||
let database_url = format!("postgresql://test:test@localhost:{}/test", port);
|
||||
let db = crate::db::Database::new(&database_url).await.unwrap();
|
||||
|
||||
// Create regular local user
|
||||
let create_user = CreateUser {
|
||||
username: "localuser".to_string(),
|
||||
email: "local@example.com".to_string(),
|
||||
password: "password123".to_string(),
|
||||
role: Some(UserRole::User),
|
||||
};
|
||||
|
||||
let user = db.create_user(create_user).await.unwrap();
|
||||
|
||||
assert_eq!(user.auth_provider, AuthProvider::Local);
|
||||
assert!(user.password_hash.is_some());
|
||||
assert!(user.oidc_subject.is_none());
|
||||
|
||||
// Test login still works
|
||||
let login_data = json!({
|
||||
"username": "localuser",
|
||||
"password": "password123"
|
||||
});
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/login")
|
||||
.header("Content-Type", "application/json")
|
||||
.body(axum::body::Body::from(serde_json::to_vec(&login_data).unwrap()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
}
|
||||
|
|
@ -47,6 +47,11 @@ async fn create_test_app_state() -> Arc<AppState> {
|
|||
max_file_size_mb: 50,
|
||||
memory_limit_mb: 512,
|
||||
cpu_priority: "normal".to_string(),
|
||||
oidc_enabled: false,
|
||||
oidc_client_id: None,
|
||||
oidc_client_secret: None,
|
||||
oidc_issuer_url: None,
|
||||
oidc_redirect_uri: None,
|
||||
};
|
||||
|
||||
let db = Database::new(&config.database_url).await.unwrap();
|
||||
|
|
@ -62,6 +67,7 @@ async fn create_test_app_state() -> Arc<AppState> {
|
|||
webdav_scheduler: None,
|
||||
source_scheduler: None,
|
||||
queue_service,
|
||||
oidc_client: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -45,6 +45,11 @@ async fn create_test_app_state() -> Arc<AppState> {
|
|||
max_file_size_mb: 10,
|
||||
memory_limit_mb: 256,
|
||||
cpu_priority: "normal".to_string(),
|
||||
oidc_enabled: false,
|
||||
oidc_client_id: None,
|
||||
oidc_client_secret: None,
|
||||
oidc_issuer_url: None,
|
||||
oidc_redirect_uri: None,
|
||||
};
|
||||
|
||||
let db = Database::new(&config.database_url).await.unwrap();
|
||||
|
|
@ -56,6 +61,7 @@ async fn create_test_app_state() -> Arc<AppState> {
|
|||
webdav_scheduler: None,
|
||||
source_scheduler: None,
|
||||
queue_service,
|
||||
oidc_client: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -76,6 +76,11 @@ async fn create_test_app_state() -> Result<Arc<AppState>> {
|
|||
max_file_size_mb: 10,
|
||||
memory_limit_mb: 256,
|
||||
cpu_priority: "normal".to_string(),
|
||||
oidc_enabled: false,
|
||||
oidc_client_id: None,
|
||||
oidc_client_secret: None,
|
||||
oidc_issuer_url: None,
|
||||
oidc_redirect_uri: None,
|
||||
}
|
||||
});
|
||||
let db = Database::new(&config.database_url).await?;
|
||||
|
|
@ -89,6 +94,7 @@ async fn create_test_app_state() -> Result<Arc<AppState>> {
|
|||
webdav_scheduler: None,
|
||||
source_scheduler: None,
|
||||
queue_service,
|
||||
oidc_client: None,
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,11 @@ async fn create_test_app_state() -> Result<Arc<AppState>> {
|
|||
max_file_size_mb: 10,
|
||||
memory_limit_mb: 256,
|
||||
cpu_priority: "normal".to_string(),
|
||||
oidc_enabled: false,
|
||||
oidc_client_id: None,
|
||||
oidc_client_secret: None,
|
||||
oidc_issuer_url: None,
|
||||
oidc_redirect_uri: None,
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -43,6 +48,7 @@ async fn create_test_app_state() -> Result<Arc<AppState>> {
|
|||
webdav_scheduler: None,
|
||||
source_scheduler: None,
|
||||
queue_service,
|
||||
oidc_client: None,
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,11 @@ async fn create_test_app_state() -> Arc<AppState> {
|
|||
max_file_size_mb: 100,
|
||||
memory_limit_mb: 512,
|
||||
cpu_priority: "normal".to_string(),
|
||||
oidc_enabled: false,
|
||||
oidc_client_id: None,
|
||||
oidc_client_secret: None,
|
||||
oidc_issuer_url: None,
|
||||
oidc_redirect_uri: None,
|
||||
};
|
||||
|
||||
let db = Database::new(&config.database_url).await.unwrap();
|
||||
|
|
@ -50,6 +55,7 @@ async fn create_test_app_state() -> Arc<AppState> {
|
|||
webdav_scheduler: None,
|
||||
source_scheduler: None,
|
||||
queue_service,
|
||||
oidc_client: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -176,6 +176,11 @@ async fn create_test_app_state() -> Arc<AppState> {
|
|||
max_file_size_mb: 10,
|
||||
memory_limit_mb: 256,
|
||||
cpu_priority: "normal".to_string(),
|
||||
oidc_enabled: false,
|
||||
oidc_client_id: None,
|
||||
oidc_client_secret: None,
|
||||
oidc_issuer_url: None,
|
||||
oidc_redirect_uri: None,
|
||||
};
|
||||
|
||||
let db = Database::new(&config.database_url).await.unwrap();
|
||||
|
|
@ -187,6 +192,7 @@ async fn create_test_app_state() -> Arc<AppState> {
|
|||
webdav_scheduler: None,
|
||||
source_scheduler: None,
|
||||
queue_service,
|
||||
oidc_client: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -113,6 +113,11 @@ async fn create_test_app_state() -> Result<Arc<AppState>> {
|
|||
max_file_size_mb: 10,
|
||||
memory_limit_mb: 256,
|
||||
cpu_priority: "normal".to_string(),
|
||||
oidc_enabled: false,
|
||||
oidc_client_id: None,
|
||||
oidc_client_secret: None,
|
||||
oidc_issuer_url: None,
|
||||
oidc_redirect_uri: None,
|
||||
}
|
||||
});
|
||||
let db = Database::new(&config.database_url).await?;
|
||||
|
|
@ -126,6 +131,7 @@ async fn create_test_app_state() -> Result<Arc<AppState>> {
|
|||
webdav_scheduler: None,
|
||||
source_scheduler: None,
|
||||
queue_service,
|
||||
oidc_client: None,
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,6 +46,11 @@ async fn create_test_app_state() -> Arc<AppState> {
|
|||
max_file_size_mb: 100,
|
||||
memory_limit_mb: 512,
|
||||
cpu_priority: "normal".to_string(),
|
||||
oidc_enabled: false,
|
||||
oidc_client_id: None,
|
||||
oidc_client_secret: None,
|
||||
oidc_issuer_url: None,
|
||||
oidc_redirect_uri: None,
|
||||
};
|
||||
|
||||
let db = Database::new(&config.database_url).await.unwrap();
|
||||
|
|
@ -61,6 +66,7 @@ async fn create_test_app_state() -> Arc<AppState> {
|
|||
webdav_scheduler: None,
|
||||
source_scheduler: None,
|
||||
queue_service,
|
||||
oidc_client: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -101,10 +101,14 @@ fn test_user_response_conversion() {
|
|||
id: Uuid::new_v4(),
|
||||
username: "testuser".to_string(),
|
||||
email: "test@example.com".to_string(),
|
||||
password_hash: "hashed".to_string(),
|
||||
password_hash: Some("hashed".to_string()),
|
||||
role: readur::models::UserRole::User,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
oidc_subject: None,
|
||||
oidc_issuer: None,
|
||||
oidc_email: None,
|
||||
auth_provider: readur::models::AuthProvider::Local,
|
||||
};
|
||||
|
||||
let response: UserResponse = user.clone().into();
|
||||
|
|
|
|||
|
|
@ -135,6 +135,11 @@ async fn create_test_app_state() -> Arc<AppState> {
|
|||
max_file_size_mb: 10,
|
||||
memory_limit_mb: 256,
|
||||
cpu_priority: "normal".to_string(),
|
||||
oidc_enabled: false,
|
||||
oidc_client_id: None,
|
||||
oidc_client_secret: None,
|
||||
oidc_issuer_url: None,
|
||||
oidc_redirect_uri: None,
|
||||
};
|
||||
|
||||
let db = Database::new(&config.database_url).await.unwrap();
|
||||
|
|
@ -146,6 +151,7 @@ async fn create_test_app_state() -> Arc<AppState> {
|
|||
webdav_scheduler: None,
|
||||
source_scheduler: None,
|
||||
queue_service,
|
||||
oidc_client: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -340,6 +340,11 @@ fn test_webdav_scheduler_creation() {
|
|||
max_file_size_mb: 50,
|
||||
ocr_language: "eng".to_string(),
|
||||
ocr_timeout_seconds: 300,
|
||||
oidc_enabled: false,
|
||||
oidc_client_id: None,
|
||||
oidc_client_secret: None,
|
||||
oidc_issuer_url: None,
|
||||
oidc_redirect_uri: None,
|
||||
};
|
||||
|
||||
// Note: This is a minimal test since we can't easily mock the database
|
||||
|
|
|
|||
|
|
@ -117,6 +117,11 @@ async fn create_test_app_state() -> Result<Arc<AppState>> {
|
|||
max_file_size_mb: 10,
|
||||
memory_limit_mb: 256,
|
||||
cpu_priority: "normal".to_string(),
|
||||
oidc_enabled: false,
|
||||
oidc_client_id: None,
|
||||
oidc_client_secret: None,
|
||||
oidc_issuer_url: None,
|
||||
oidc_redirect_uri: None,
|
||||
}
|
||||
});
|
||||
let db = Database::new(&config.database_url).await?;
|
||||
|
|
@ -130,6 +135,7 @@ async fn create_test_app_state() -> Result<Arc<AppState>> {
|
|||
webdav_scheduler: None,
|
||||
source_scheduler: None,
|
||||
queue_service,
|
||||
oidc_client: None,
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -93,6 +93,11 @@ async fn setup_test_app() -> (Router, Arc<AppState>) {
|
|||
max_file_size_mb: 50,
|
||||
ocr_language: "eng".to_string(),
|
||||
ocr_timeout_seconds: 300,
|
||||
oidc_enabled: false,
|
||||
oidc_client_id: None,
|
||||
oidc_client_secret: None,
|
||||
oidc_issuer_url: None,
|
||||
oidc_redirect_uri: None,
|
||||
};
|
||||
|
||||
// Use the environment-based database URL
|
||||
|
|
@ -106,6 +111,7 @@ async fn setup_test_app() -> (Router, Arc<AppState>) {
|
|||
webdav_scheduler: None,
|
||||
source_scheduler: None,
|
||||
queue_service,
|
||||
oidc_client: None,
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
|
|
|
|||
Loading…
Reference in New Issue