diff --git a/Cargo.lock b/Cargo.lock
index 818f5cb..6f84c5d 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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"
diff --git a/Cargo.toml b/Cargo.toml
index b9107ce..48875ac 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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
diff --git a/docs/oidc-setup.md b/docs/oidc-setup.md
new file mode 100644
index 0000000..430e16b
--- /dev/null
+++ b/docs/oidc-setup.md
@@ -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
\ No newline at end of file
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 0ae64ce..397b106 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -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 {
: } />
+ } />
{
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
+ const [oidcLoading, setOidcLoading] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
const { mode } = useTheme();
@@ -63,6 +66,18 @@ const Login: React.FC = () => {
setShowPassword(!showPassword);
};
+ const handleOidcLogin = async (): Promise => {
+ 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 (
{
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'}
+
+
+ or
+
+
+
+ }
+ 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'}
+
+
Demo credentials: admin / readur2024
diff --git a/frontend/src/components/Auth/OidcCallback.tsx b/frontend/src/components/Auth/OidcCallback.tsx
new file mode 100644
index 0000000..05a3b33
--- /dev/null
+++ b/frontend/src/components/Auth/OidcCallback.tsx
@@ -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('');
+ const [processing, setProcessing] = useState(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 (
+
+
+
+ {processing ? (
+ <>
+
+
+ Completing Authentication
+
+
+ Please wait while we process your authentication...
+
+ >
+ ) : (
+ <>
+
+ Return to Login
+
+ }
+ >
+
+ Authentication Error
+
+ {error}
+
+ >
+ )}
+
+
+
+ );
+};
+
+export default OidcCallback;
\ No newline at end of file
diff --git a/frontend/src/components/Auth/__tests__/Login.oidc.test.tsx b/frontend/src/components/Auth/__tests__/Login.oidc.test.tsx
new file mode 100644
index 0000000..9232e0b
--- /dev/null
+++ b/frontend/src/components/Auth/__tests__/Login.oidc.test.tsx
@@ -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 }) => (
+
+ {children}
+
+);
+
+const MockThemeProvider = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+);
+
+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(
+
+
+
+
+
+
+
+ );
+ };
+
+ 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();
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/components/Auth/__tests__/OidcCallback.test.tsx b/frontend/src/components/Auth/__tests__/OidcCallback.test.tsx
new file mode 100644
index 0000000..a2cf8e9
--- /dev/null
+++ b/frontend/src/components/Auth/__tests__/OidcCallback.test.tsx
@@ -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 }) => (
+
+ {children}
+
+);
+
+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(
+
+
+
+ } />
+ Login Page} />
+
+
+
+ );
+ };
+
+ 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();
+ });
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts
index ea93d68..2286153 100644
--- a/frontend/src/test/setup.ts
+++ b/frontend/src/test/setup.ts
@@ -19,4 +19,19 @@ vi.mock('axios', () => ({
defaults: { headers: { common: {} } },
})),
},
-}))
\ No newline at end of file
+}))
+
+// 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(),
+ })),
+})
\ No newline at end of file
diff --git a/migrations/20250626000001_add_oidc_user_mapping.sql b/migrations/20250626000001_add_oidc_user_mapping.sql
new file mode 100644
index 0000000..d59a777
--- /dev/null
+++ b/migrations/20250626000001_add_oidc_user_mapping.sql
@@ -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)
+ );
\ No newline at end of file
diff --git a/src/config.rs b/src/config.rs
index d47f63e..e3a763b 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -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,
+ pub oidc_client_secret: Option,
+ pub oidc_issuer_url: Option,
+ pub oidc_redirect_uri: Option,
}
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)
diff --git a/src/db/mod.rs b/src/db/mod.rs
index 288f28c..e1d28bf 100644
--- a/src/db/mod.rs
+++ b/src/db/mod.rs
@@ -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(
diff --git a/src/db/users.rs b/src/db/users.rs
index cfd12d2..979a57b 100644
--- a/src/db/users.rs
+++ b/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::("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::("auth_provider").try_into().unwrap_or(AuthProvider::Local),
})
}
pub async fn get_user_by_username(&self, username: &str) -> Result