Merge pull request #55 from readur/feat/oidc-setup

feat(server): set up oidc system and migrations
This commit is contained in:
Jon Fuller 2025-06-27 10:48:28 -07:00 committed by GitHub
commit b095cb951f
42 changed files with 3370 additions and 59 deletions

412
Cargo.lock generated
View File

@ -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"

View File

@ -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

439
docs/oidc-setup.md Normal file
View File

@ -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

View File

@ -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={

View File

@ -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

View File

@ -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;

View File

@ -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();
});
});

View File

@ -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();
});
});
});

View File

@ -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(),
})),
})

View File

@ -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)
);

View File

@ -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)

View File

@ -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(

View File

@ -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),
})
}
}

View File

@ -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

View File

@ -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);

View File

@ -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)]

352
src/oidc.rs Normal file
View File

@ -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());
}
}

View File

@ -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(),
}))
}

View File

@ -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"
});

View File

@ -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,
}
}

View File

@ -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");
}
}

View File

@ -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]

View File

@ -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,
}
}

View File

@ -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");

View File

@ -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()

View File

@ -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");

View File

@ -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;

423
src/tests/oidc_tests.rs Normal file
View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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,
})
}

View File

@ -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,
})
}

View File

@ -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,
}))
}

View File

@ -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,
}))
}

View File

@ -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,
})
}

View File

@ -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,
})
}

View File

@ -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,
}))
}

View File

@ -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,
})
}

View File

@ -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();

View File

@ -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,
})
}

View File

@ -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

View File

@ -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,
}))
}

View File

@ -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()