diff --git a/.env.template b/.env.template index 6d272388..538e55c0 100644 --- a/.env.template +++ b/.env.template @@ -174,6 +174,10 @@ ## Cron schedule of the job that cleans expired Duo contexts from the database. Does nothing if Duo MFA is disabled or set to use the legacy iframe prompt. ## Defaults to every minute. Set blank to disable this job. # DUO_CONTEXT_PURGE_SCHEDULE="30 * * * * *" +# +## Cron schedule of the job that cleans sso nonce from incomplete flow +## Defaults to daily (20 minutes after midnight). Set blank to disable this job. +# PURGE_INCOMPLETE_SSO_NONCE="0 20 0 * * *" ######################## ### General settings ### @@ -459,6 +463,42 @@ ## Setting this to true will enforce the Single Org Policy to be enabled before you can enable the Reset Password policy. # ENFORCE_SINGLE_ORG_WITH_RESET_PW_POLICY=false +##################################### +### SSO settings (OpenID Connect) ### +##################################### + +## Controls whether users can login using an OpenID Connect identity provider +# SSO_ENABLED=false +## Prevent users from logging in directly without going through SSO +# SSO_ONLY=false +## On SSO Signup if a user with a matching email already exists make the association +# SSO_SIGNUPS_MATCH_EMAIL=true +## Allow unknown email verification status. Allowing this with `SSO_SIGNUPS_MATCH_EMAIL=true` open potential account takeover. +# SSO_ALLOW_UNKNOWN_EMAIL_VERIFICATION=false +## Base URL of the OIDC server (auto-discovery is used) +## - Should not include the `/.well-known/openid-configuration` part and no trailing `/` +## - ${SSO_AUTHORITY}/.well-known/openid-configuration should return a json document: https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationResponse +# SSO_AUTHORITY=https://auth.example.com +## Authorization request scopes. Optional SSO scopes, override if email and profile are not enough (`openid` is implicit). +#SSO_SCOPES="email profile" +## Additionnal authorization url parameters (ex: to obtain a `refresh_token` with Google Auth). +# SSO_AUTHORIZE_EXTRA_PARAMS="access_type=offline&prompt=consent" +## Activate PKCE for the Auth Code flow. +# SSO_PKCE=true +## Regex to add additionnal trusted audience to Id Token (by default only the client_id is trusted). +# SSO_AUDIENCE_TRUSTED='^$' +## Set your Client ID and Client Key +# SSO_CLIENT_ID=11111 +# SSO_CLIENT_SECRET=AAAAAAAAAAAAAAAAAAAAAAAA +## Optional Master password policy (minComplexity=[0-4]), `enforceOnLogin` is not supported at the moment. +# SSO_MASTER_PASSWORD_POLICY='{"enforceOnLogin":false,"minComplexity":3,"minLength":12,"requireLower":false,"requireNumbers":false,"requireSpecial":false,"requireUpper":false}' +## Use sso only for authentication not the session lifecycle +# SSO_AUTH_ONLY_NOT_SESSION=false +## Client cache for discovery endpoint. Duration in seconds (0 to disable). +# SSO_CLIENT_CACHE_EXPIRATION=0 +## Log all the tokens, LOG_LEVEL=debug is required +# SSO_DEBUG_TOKENS=false + ######################## ### MFA/2FA settings ### ######################## diff --git a/Cargo.lock b/Cargo.lock index d4ecc9e0..93a54a04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -128,9 +128,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.25" +version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40f6024f3f856663b45fd0c9b6f2024034a702f453549449e0d84a305900dad4" +checksum = "ddb939d66e4ae03cee6091612804ba446b12878410cfa17f785f4dd67d4014e8" dependencies = [ "brotli", "flate2", @@ -657,6 +657,12 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.13.1" @@ -783,6 +789,12 @@ version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "bytemuck" version = "1.23.1" @@ -847,6 +859,37 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0" +[[package]] +name = "camino" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0da45bc31171d8d6960122e222a67740df867c1dd53b4d51caa297084c185cab" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4acbb09d9ee8e23699b9634375c72795d095bf268439da88562cf9b501f181fa" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", +] + [[package]] name = "cbc" version = "0.1.2" @@ -1040,9 +1083,9 @@ dependencies = [ [[package]] name = "crc32fast" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ "cfg-if", ] @@ -1094,6 +1137,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -1104,6 +1159,33 @@ dependencies = [ "typenum", ] +[[package]] +name = "curve25519-dalek" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "373b7c5dbd637569a2cca66e8d66b8c446a1e7bf064ea321d265d7b3dfe7c97e" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "darling" version = "0.20.11" @@ -1139,6 +1221,19 @@ dependencies = [ "syn", ] +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "dashmap" version = "6.1.0" @@ -1183,6 +1278,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" dependencies = [ "powerfmt", + "serde", ] [[package]] @@ -1409,12 +1505,77 @@ dependencies = [ "syn", ] +[[package]] +name = "dyn-clone" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "email-encoding" version = "0.4.1" @@ -1477,6 +1638,15 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "error-chain" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2f06b9cac1506ece98fe3231e3cc9c4410ec3d5b1f24ae1c8946f0742cdefc" +dependencies = [ + "version_check", +] + [[package]] name = "event-listener" version = "2.5.3" @@ -1522,6 +1692,22 @@ dependencies = [ "syslog", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + [[package]] name = "figment" version = "0.10.19" @@ -1725,6 +1911,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1785,7 +1972,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3cbe789d04bf14543f03c4b60cd494148aa79438c8440ae7d81a7778147745c3" dependencies = [ "cfg-if", - "dashmap", + "dashmap 6.1.0", "futures-sink", "futures-timer", "futures-util", @@ -1808,12 +1995,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d9e3df7f0222ce5184154973d247c591d9aadc28ce7a73c6cd31100c9facff6" dependencies = [ "codemap", - "indexmap", + "indexmap 2.10.0", "lasso", "once_cell", "phf 0.11.3", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "h2" version = "0.4.11" @@ -1826,7 +2024,7 @@ dependencies = [ "futures-core", "futures-sink", "http 1.3.1", - "indexmap", + "indexmap 2.10.0", "slab", "tokio", "tokio-util", @@ -1856,6 +2054,12 @@ dependencies = [ "walkdir", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.14.5" @@ -1941,6 +2145,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + [[package]] name = "hmac" version = "0.12.1" @@ -2108,6 +2321,22 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper 1.6.0", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.15" @@ -2271,6 +2500,17 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.10.0" @@ -2348,6 +2588,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.15" @@ -2638,6 +2887,21 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mini-moka" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c325dfab65f261f386debee8b0969da215b3fa0037e74c8a1234db7ba986d803" +dependencies = [ + "crossbeam-channel", + "crossbeam-utils", + "dashmap 5.5.3", + "skeptic", + "smallvec", + "tagptr", + "triomphe", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -2713,6 +2977,23 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework 2.11.1", + "security-framework-sys", + "tempfile", +] + [[package]] name = "nom" version = "7.1.3" @@ -2856,6 +3137,26 @@ dependencies = [ "libc", ] +[[package]] +name = "oauth2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" +dependencies = [ + "base64 0.22.1", + "chrono", + "getrandom 0.2.16", + "http 1.3.1", + "rand 0.8.5", + "reqwest", + "serde", + "serde_json", + "serde_path_to_error", + "sha2", + "thiserror 1.0.69", + "url", +] + [[package]] name = "object" version = "0.36.7" @@ -2904,6 +3205,37 @@ dependencies = [ "uuid", ] +[[package]] +name = "openidconnect" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c6709ba2ea764bbed26bce1adf3c10517113ddea6f2d4196e4851757ef2b2" +dependencies = [ + "base64 0.21.7", + "chrono", + "dyn-clone", + "ed25519-dalek", + "hmac", + "http 1.3.1", + "itertools", + "log", + "oauth2", + "p256", + "p384", + "rand 0.8.5", + "rsa", + "serde", + "serde-value", + "serde_json", + "serde_path_to_error", + "serde_plain", + "serde_with", + "sha2", + "subtle", + "thiserror 1.0.69", + "url", +] + [[package]] name = "openssl" version = "0.10.73" @@ -2958,6 +3290,15 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + [[package]] name = "ordered-multimap" version = "0.7.3" @@ -2980,6 +3321,30 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + [[package]] name = "parking" version = "2.2.1" @@ -3322,6 +3687,15 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" version = "1.0.95" @@ -3369,6 +3743,17 @@ dependencies = [ "psl-types", ] +[[package]] +name = "pulldown-cmark" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57206b407293d2bcd3af849ce869d52068623f19e1b5ff8e8778e3309439682b" +dependencies = [ + "bitflags", + "memchr", + "unicase", +] + [[package]] name = "quanta" version = "0.12.6" @@ -3699,10 +4084,12 @@ dependencies = [ "http-body-util", "hyper 1.6.0", "hyper-rustls", + "hyper-tls", "hyper-util", "js-sys", "log", "mime", + "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -3714,6 +4101,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-native-tls", "tokio-rustls 0.26.2", "tokio-util", "tower", @@ -3733,6 +4121,16 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95325155c684b1c89f7765e30bc1c42e4a6da51ca513615660cb8a62ef9a88e3" +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -3782,7 +4180,7 @@ dependencies = [ "either", "figment", "futures", - "indexmap", + "indexmap 2.10.0", "log", "memchr", "multer", @@ -3814,7 +4212,7 @@ checksum = "575d32d7ec1a9770108c879fc7c47815a80073f96ca07ff9525a94fcede1dd46" dependencies = [ "devise", "glob", - "indexmap", + "indexmap 2.10.0", "proc-macro2", "quote", "rocket_http", @@ -3834,7 +4232,7 @@ dependencies = [ "futures", "http 0.2.12", "hyper 0.14.32", - "indexmap", + "indexmap 2.10.0", "log", "memchr", "pear", @@ -3938,15 +4336,15 @@ dependencies = [ [[package]] name = "rustix" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" +checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" dependencies = [ "bitflags", "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -3985,7 +4383,7 @@ dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.2.0", ] [[package]] @@ -4076,6 +4474,30 @@ dependencies = [ "parking_lot", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -4109,6 +4531,33 @@ dependencies = [ "untrusted", ] +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.2.0" @@ -4137,6 +4586,9 @@ name = "semver" version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" +dependencies = [ + "serde", +] [[package]] name = "serde" @@ -4147,6 +4599,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + [[package]] name = "serde_cbor" version = "0.11.2" @@ -4180,6 +4642,25 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a" +dependencies = [ + "itoa", + "serde", +] + +[[package]] +name = "serde_plain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" +dependencies = [ + "serde", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -4210,6 +4691,38 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2c45cd61fefa9db6f254525d46e392b852e0e61d9a1fd36e5bd183450a556d5" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.10.0", + "schemars 0.9.0", + "schemars 1.0.4", + "serde", + "serde_derive", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de90945e6565ce0d9a25098082ed4ee4002e047cb59892c318d66821e14bb30f" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sha1" version = "0.10.6" @@ -4294,6 +4807,21 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" +[[package]] +name = "skeptic" +version = "0.13.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16d23b015676c90a0f01c197bfdc786c20342c73a0afdda9025adb0bc42940a8" +dependencies = [ + "bytecount", + "cargo_metadata", + "error-chain", + "glob", + "pulldown-cmark", + "tempfile", + "walkdir", +] + [[package]] name = "slab" version = "0.4.10" @@ -4643,6 +5171,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.24.1" @@ -4718,7 +5256,7 @@ version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed0aee96c12fa71097902e0bb061a5e1ebd766a6636bb605ba401c45c1650eac" dependencies = [ - "indexmap", + "indexmap 2.10.0", "serde", "serde_spanned 1.0.0", "toml_datetime 0.7.0", @@ -4751,7 +5289,7 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap", + "indexmap 2.10.0", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", @@ -4899,6 +5437,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "triomphe" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8f7726da4807b58ea5c96fdc122f80702030edc33b35aff9190a51148ccc85" + [[package]] name = "try-lock" version = "0.2.5" @@ -4955,6 +5499,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + [[package]] name = "unicode-ident" version = "1.0.18" @@ -5043,7 +5593,7 @@ dependencies = [ "chrono-tz", "cookie", "cookie_store", - "dashmap", + "dashmap 6.1.0", "data-encoding", "data-url", "derive_more", @@ -5068,10 +5618,12 @@ dependencies = [ "log", "macros", "mimalloc", + "mini-moka", "num-derive", "num-traits", "once_cell", "opendal", + "openidconnect", "openssl", "pastey", "percent-encoding", diff --git a/Cargo.toml b/Cargo.toml index 4ae0c413..ed725927 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,10 @@ enable_mimalloc = ["dep:mimalloc"] query_logger = ["dep:diesel_logger"] s3 = ["opendal/services-s3", "dep:aws-config", "dep:aws-credential-types", "dep:aws-smithy-runtime-api", "dep:anyhow", "dep:http", "dep:reqsign"] +# OIDC specific features +oidc-accept-rfc3339-timestamps = ["openidconnect/accept-rfc3339-timestamps"] +oidc-accept-string-booleans = ["openidconnect/accept-string-booleans"] + # Enable unstable features, requires nightly # Currently only used to enable rusts official ip support unstable = [] @@ -161,6 +165,10 @@ pico-args = "0.5.0" pastey = "0.1.0" governor = "0.10.0" +# OIDC for SSO +openidconnect = { version = "4.0.1", features = ["reqwest", "native-tls"] } +mini-moka = "0.10.2" + # Check client versions for specific features. semver = "1.0.26" diff --git a/SSO.md b/SSO.md new file mode 100644 index 00000000..646b1bd4 --- /dev/null +++ b/SSO.md @@ -0,0 +1,318 @@ +# SSO using OpenId Connect + +To use an external source of authentication your SSO will need to support OpenID Connect : + +- An OpenID Connect Discovery endpoint should be available +- Client authentication will be done using Id and Secret. + +A master password will still be required and not controlled by the SSO (depending on your point of view this might be a feature ;). +This introduces another way to control who can use the vault without having to use invitation or using an LDAP. + +## Configuration + +The following configurations are available + +- `SSO_ENABLED` : Activate the SSO +- `SSO_ONLY` : disable email+Master password authentication +- `SSO_SIGNUPS_MATCH_EMAIL`: On SSO Signup if a user with a matching email already exists make the association (default `true`) +- `SSO_ALLOW_UNKNOWN_EMAIL_VERIFICATION`: Allow unknown email verification status (default `false`). Allowing this with `SSO_SIGNUPS_MATCH_EMAIL` open potential account takeover. +- `SSO_AUTHORITY` : the OpenID Connect Discovery endpoint of your SSO + - Should not include the `/.well-known/openid-configuration` part and no trailing `/` + - $SSO_AUTHORITY/.well-known/openid-configuration should return the a json document: https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationResponse +- `SSO_SCOPES` : Optional, allow to override scopes if needed (default `"email profile"`) +- `SSO_AUTHORIZE_EXTRA_PARAMS` : Optional, allow to add extra parameter to the authorize redirection (default `""`) +- `SSO_PKCE`: Activate PKCE for the Auth Code flow (default `true`). +- `SSO_AUDIENCE_TRUSTED`: Optional, Regex to trust additional audience for the IdToken (`client_id` is always trusted). Use single quote when writing the regex: `'^$'`. +- `SSO_CLIENT_ID` : Client Id +- `SSO_CLIENT_SECRET` : Client Secret +- `SSO_MASTER_PASSWORD_POLICY`: Optional Master password policy (`enforceOnLogin` is not supported). +- `SSO_AUTH_ONLY_NOT_SESSION`: Enable to use SSO only for authentication not session lifecycle +- `SSO_CLIENT_CACHE_EXPIRATION`: Cache calls to the discovery endpoint, duration in seconds, `0` to disable (default `0`); +- `SSO_DEBUG_TOKENS`: Log all tokens for easier debugging (default `false`, `LOG_LEVEL=debug` or `LOG_LEVEL=info,vaultwarden::sso=debug` need to be set) + +The callback url is : `https://your.domain/identity/connect/oidc-signin` + +## Account and Email handling + +When logging in with SSO an identifier (`{iss}/{sub}` claims from the IdToken) is saved in a separate table (`sso_users`). +This is used to link to the SSO provider identifier without changing the default user `uuid`. This is needed because: + +- Storing the SSO identifier is important to prevent account takeover due to email change. +- We can't use the identifier as the User uuid since it's way longer (Max 255 chars for the `sub` part, cf [spec](https://openid.net/specs/openid-connect-core-1_0.html#CodeIDToken)). +- We want to be able to associate existing account based on `email` but only when the user logs in for the first time (controlled by `SSO_SIGNUPS_MATCH_EMAIL`). +- We need to be able to associate with existing stub account, such as the one created when inviting a user to an org (association is possible only if the user does not have a private key). + +Additionally: + +- Signup will be blocked if the Provider reports the email as `unverified`. +- Changing the email needs to be done by the user since it requires updating the `key`. + On login if the email returned by the provider is not the one saved an email will be sent to the user to ask him to update it. +- If set `SIGNUPS_DOMAINS_WHITELIST` is applied on SSO signup and when attempting to change the email. + +This means that if you ever need to change the provider url or the provider itself; you'll have to first delete the association +then ensure that `SSO_SIGNUPS_MATCH_EMAIL` is activated to allow a new association. + +To delete the association (this has no impact on the `Vaultwarden` user): + +```sql +TRUNCATE TABLE sso_users; +``` + +### On `SSO_ALLOW_UNKNOWN_EMAIL_VERIFICATION` + +If your provider does not send the verification status of emails (`email_verified` [claim](https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims)) you will need to activate this setting. + +If set with `SSO_SIGNUPS_MATCH_EMAIL=true` (the default), then a user can associate with an existing, non-SSO account, even if they do not control the email address. +This allow a user to gain access to sensitive information but the master password is still required to read the passwords. + +As such when using `SSO_ALLOW_UNKNOWN_EMAIL_VERIFICATION` it is recommended to disable `SSO_SIGNUPS_MATCH_EMAIL`. +If you need to associate non sso users try to keep both settings activated for the shortest time possible. + +## Client Cache + +By default the client cache is disabled since it can cause issues with the signing keys. +\ +This means that the discovery endpoint will be called again each time we need to interact with the provider (generating authorize_url, exchange the authorize code, refresh tokens). +This is suboptimal so the `SSO_CLIENT_CACHE_EXPIRATION` allows you to configure an expiration that should work for your provider. + +As a protection against a misconfigured expiration if the validation of the `IdToken` fails then the client cache is invalidated (but you'll periodically have an unlucky user ^^). + +### Google example (Rolling keys) + +If we take Google as an example checking the discovery [endpoint](https://accounts.google.com/.well-known/openid-configuration) response headers we can see that the `max-age` of the cache control is set to `3600` seconds. And the [jwk_uri](https://www.googleapis.com/oauth2/v3/certs) response headers usually contain a `max-age` with an even bigger value. +/ +Combined with user [feedback](https://github.com/ramosbugs/openidconnect-rs/issues/152) we can conclude that Google will roll the signing keys each week. + +Setting the cache expiration too high has diminishing return but using something like `600` (10 min) should provide plenty benefits. + +### Rolling keys manually + +If you want to roll the used key, first add a new one but do not immediately start signing with it. +Wait for the delay you configured in `SSO_CLIENT_CACHE_EXPIRATION` then you can start signing with it. + +As mentioned in the Google example setting too high of a value has diminishing return even if you do not plan to roll the keys. + +## Keycloak + +Default access token lifetime might be only `5min`, set a longer value otherwise it will collide with `Bitwarden` front-end expiration detection which is also set at `5min`. +\ +At the realm level + +- `Realm settings / Tokens / Access Token Lifespan` to at least `10min` (`accessTokenLifespan` setting when using `kcadm.sh`). +- `Realm settings / Sessions / SSO Session Idle/Max` for the Refresh token lifetime + +Or for a specific client in `Clients / Client details / Advanced / Advanced settings` you can find `Access Token Lifespan` and `Client Session Idle/Max`. + +Server configuration, nothing specific just set: + +- `SSO_AUTHORITY=https://${domain}/realms/${realm_name}` +- `SSO_CLIENT_ID` +- `SSO_CLIENT_SECRET` + +### Testing + +If you want to run a testing instance of Keycloak the Playwright [docker-compose](playwright/docker-compose.yml) can be used. +\ +More details on how to use it in [README.md](playwright/README.md#openid-connect-test-setup). + +## Auth0 + +Not working due to the following issue https://github.com/ramosbugs/openidconnect-rs/issues/23 (they appear not to follow the spec). +A feature flag is available to bypass the issue but since it's a compile time feature you will have to patch with something like: + +```patch +diff --git a/Cargo.toml b/Cargo.toml +index 0524a7be..9999e852 100644 +--- a/Cargo.toml ++++ b/Cargo.toml +@@ -150,7 +150,7 @@ paste = "1.0.15" + governor = "0.6.3" + + # OIDC for SSO +-openidconnect = "3.5.0" ++openidconnect = { version = "3.5.0", features = ["accept-rfc3339-timestamps"] } + mini-moka = "0.10.2" +``` + +There is no plan at the moment to either always activate the feature nor make a specific distribution for Auth0. + +## Authelia + +To obtain a `refresh_token` to be able to extend session you'll need to add the `offline_access` scope. + +Config will look like: + +- `SSO_SCOPES="email profile offline_access"` + + +## Authentik + +Default access token lifetime might be only `5min`, set a longer value otherwise it will collide with `Bitwarden` front-end expiration detection which is also set at `5min`. +\ +To change the tokens expiration go to `Applications / Providers / Edit / Advanced protocol settings`. + +Starting with `2024.2` version you will need to add the `offline_access` scope and ensure it's selected in `Applications / Providers / Edit / Advanced protocol settings / Scopes` ([Doc](https://docs.goauthentik.io/docs/providers/oauth2/#authorization_code)). + +Server configuration should look like: + +- `SSO_AUTHORITY=https://${domain}/application/o/${application_name}/` : trailing `/` is important +- `SSO_SCOPES="email profile offline_access"` +- `SSO_CLIENT_ID` +- `SSO_CLIENT_SECRET` + +## Casdoor + +Since version [v1.639.0](https://github.com/casdoor/casdoor/releases/tag/v1.639.0) should work (Tested with version [v1.686.0](https://github.com/casdoor/casdoor/releases/tag/v1.686.0)). +When creating the application you will need to select the `Token format -> JWT-Standard`. + +Then configure your server with: + +- `SSO_AUTHORITY=https://${provider_host}` +- `SSO_CLIENT_ID` +- `SSO_CLIENT_SECRET` + +## GitLab + +Create an application in your Gitlab Settings with + +- `redirectURI`: https://your.domain/identity/connect/oidc-signin +- `Confidential`: `true` +- `scopes`: `openid`, `profile`, `email` + +Then configure your server with + +- `SSO_AUTHORITY=https://gitlab.com` +- `SSO_CLIENT_ID` +- `SSO_CLIENT_SECRET` + +## Google Auth + +Google [Documentation](https://developers.google.com/identity/openid-connect/openid-connect). +\ +By default without extra [configuration](https://developers.google.com/identity/protocols/oauth2/web-server#creatingclient) you won´t have a `refresh_token` and session will be limited to 1h. + +Configure your server with : + +- `SSO_AUTHORITY=https://accounts.google.com` +- `SSO_AUTHORIZE_EXTRA_PARAMS="access_type=offline&prompt=consent"` +- `SSO_CLIENT_ID` +- `SSO_CLIENT_SECRET` + +## Kanidm + +Nothing specific should work with just `SSO_AUTHORITY`, `SSO_CLIENT_ID` and `SSO_CLIENT_SECRET`. + +## Microsoft Entra ID + +1. Create an "App registration" in [Entra ID](https://entra.microsoft.com/) following [Identity | Applications | App registrations](https://entra.microsoft.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade/quickStartType//sourceType/Microsoft_AAD_IAM). +2. From the "Overview" of your "App registration", you'll need the "Directory (tenant) ID" for the `SSO_AUTHORITY` variable and the "Application (client) ID" as the `SSO_CLIENT_ID` value. +3. In "Certificates & Secrets" create an "App secret" , you'll need the "Secret Value" for the `SSO_CLIENT_SECRET` variable. +4. In "Authentication" add as "Web Redirect URI". +5. In "API Permissions" make sure you have `profile`, `email` and `offline_access` listed under "API / Permission name" (`offline_access` is required, otherwise no refresh_token is returned, see ). + +Only the v2 endpoint is compliant with the OpenID spec, see and . + +Your configuration should look like this: + +* `SSO_AUTHORITY=https://login.microsoftonline.com/${Directory (tenant) ID}/v2.0` +* `SSO_SCOPES="email profile offline_access"` +* `SSO_CLIENT_ID=${Application (client) ID}` +* `SSO_CLIENT_SECRET=${Secret Value}` + +## Rauthy + +To use a provider controlled session you will need to run Rauthy with `DISABLE_REFRESH_TOKEN_NBF=true` otherwise the server will fail when trying to read a not yet valid `refresh_token` (`Bitwarden` clients will trigger a refresh even if the `access_token` is still valid. Details on rauthy [side](https://github.com/sebadob/rauthy/issues/651)). Alternative is to use the default session handling with `SSO_AUTH_ONLY_NOT_SESSION=true`. + +No specific config needed when creating the Client. + +Your configuration should look like this: + +* `SSO_AUTHORITY=http://${provider_host}/auth/v1` +* `SSO_CLIENT_ID=${Client ID}` +* `SSO_CLIENT_SECRET=${Client Secret}` +* `SSO_AUTH_ONLY_NOT_SESSION=true` Only needed if not running `Rauthy` with `DISABLE_REFRESH_TOKEN_NBF=true` + +## Slack + +You will need to create an app in https://api.slack.com/apps/. + +It appears that the `access_token` returned is not in JWT format and an expiration date is not sent with it. As such you will need to use the default session lifecycle. + +Your configuration should look like this: + +* `SSO_AUTHORITY=https://slack.com` +* `SSO_CLIENT_ID=${Application Client ID}` +* `SSO_CLIENT_SECRET=${Application Client Secret}` +* `SSO_AUTH_ONLY_NOT_SESSION=true` + +## Zitadel + +To obtain a `refresh_token` to be able to extend session you'll need to add the `offline_access` scope. + +Additionally Zitadel include the `Project id` and the `Client Id` in the audience of the Id Token. +For the validation to work you will need to add the `Resource Id` as a trusted audience (`Client Id` is trusted by default). +You can control the trusted audience with the config `SSO_AUDIENCE_TRUSTED` + +Since [zitadel#721](https://github.com/zitadel/oidc/pull/721) PKCE should work with client secret. +But older versions might have to disable it (`SSO_PKCE=false`). + +Config will look like: + +- `SSO_AUTHORITY=https://${provider_host}` +- `SSO_SCOPES="email profile offline_access"` +- `SSO_CLIENT_ID` +- `SSO_CLIENT_SECRET` +- `SSO_AUDIENCE_TRUSTED='^${Project Id}$'` + +## Session lifetime + +Session lifetime is dependant on refresh token and access token returned after calling your SSO token endpoint (grant type `authorization_code`). +If no refresh token is returned then the session will be limited to the access token lifetime. + +Tokens are not persisted in the server but wrapped in JWT tokens and returned to the application (The `refresh_token` and `access_token` values returned by VW `identity/connect/token` endpoint). +Note that the server will always return a `refresh_token` for compatibility reasons with the web front and it presence does not indicate that a refresh token was returned by your SSO (But you can decode its value with and then check if the `token` field contain anything). + +With a refresh token present, activity in the application will trigger a refresh of the access token when it's close to expiration ([5min](https://github.com/bitwarden/clients/blob/0bcb45ed5caa990abaff735553a5046e85250f24/libs/common/src/auth/services/token.service.ts#L126) in web client). + +Additionally for certain action a token check is performed, if we have a refresh token we will perform a refresh otherwise we'll call the user information endpoint to check the access token validity. + +### Disabling SSO session handling + +If you are unable to obtain a `refresh_token` or for any other reason you can disable SSO session handling and revert to the default handling. +You'll need to enable `SSO_AUTH_ONLY_NOT_SESSION=true` then access token will be valid for 2h and refresh token will allow for an idle time of 7 days (which can be indefinitely extended). + +### Debug information + +Running with `LOG_LEVEL=debug` you'll be able to see information on token expiration. + +## Desktop Client + +There is some issue to handle redirection from your browser (used for sso login) to the application. + +### Chrome + +Probably not much hope, an [issue](https://github.com/bitwarden/clients/issues/2606) is open on the subject and it appears that both Linux and Windows are not working. + +## Firefox + +On Windows you'll be presented with a prompt the first time you log to confirm which application should be launched (But there is a bug at the moment you might end-up with an empty vault after login atm). + + +On Linux it's a bit more tricky. +First you'll need to add some config in `about:config` : + +```conf +network.protocol-handler.expose.bitwarden=false +network.protocol-handler.external.bitwarden=true +``` + +If you have any doubt you can check `mailto` to see how it's configured. + +The redirection will still not work since it appears that the association to an application can only be done on a link/click. You can trigger it with a dummy page such as: + +```html +data:text/html,Click me to register Bitwarden +``` + +From now on the redirection should now work. +If you need to change the application launched you can now find it in `Settings` by using the search function and entering `application`. diff --git a/migrations/mysql/2023-09-10-133000_add_sso/down.sql b/migrations/mysql/2023-09-10-133000_add_sso/down.sql new file mode 100644 index 00000000..2c946dc5 --- /dev/null +++ b/migrations/mysql/2023-09-10-133000_add_sso/down.sql @@ -0,0 +1 @@ +DROP TABLE sso_nonce; diff --git a/migrations/mysql/2023-09-10-133000_add_sso/up.sql b/migrations/mysql/2023-09-10-133000_add_sso/up.sql new file mode 100644 index 00000000..518664df --- /dev/null +++ b/migrations/mysql/2023-09-10-133000_add_sso/up.sql @@ -0,0 +1,4 @@ +CREATE TABLE sso_nonce ( + nonce CHAR(36) NOT NULL PRIMARY KEY, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/migrations/mysql/2023-09-14-133000_add_users_organizations_invited_by_email/down.sql b/migrations/mysql/2023-09-14-133000_add_users_organizations_invited_by_email/down.sql new file mode 100644 index 00000000..3a708927 --- /dev/null +++ b/migrations/mysql/2023-09-14-133000_add_users_organizations_invited_by_email/down.sql @@ -0,0 +1 @@ +ALTER TABLE users_organizations DROP COLUMN invited_by_email; diff --git a/migrations/mysql/2023-09-14-133000_add_users_organizations_invited_by_email/up.sql b/migrations/mysql/2023-09-14-133000_add_users_organizations_invited_by_email/up.sql new file mode 100644 index 00000000..c94e1131 --- /dev/null +++ b/migrations/mysql/2023-09-14-133000_add_users_organizations_invited_by_email/up.sql @@ -0,0 +1 @@ +ALTER TABLE users_organizations ADD COLUMN invited_by_email TEXT DEFAULT NULL; diff --git a/migrations/mysql/2024-02-14-170000_add_state_to_sso_nonce/down.sql b/migrations/mysql/2024-02-14-170000_add_state_to_sso_nonce/down.sql new file mode 100644 index 00000000..bce31222 --- /dev/null +++ b/migrations/mysql/2024-02-14-170000_add_state_to_sso_nonce/down.sql @@ -0,0 +1,6 @@ +DROP TABLE IF EXISTS sso_nonce; + +CREATE TABLE sso_nonce ( + nonce CHAR(36) NOT NULL PRIMARY KEY, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/migrations/mysql/2024-02-14-170000_add_state_to_sso_nonce/up.sql b/migrations/mysql/2024-02-14-170000_add_state_to_sso_nonce/up.sql new file mode 100644 index 00000000..f73aeea9 --- /dev/null +++ b/migrations/mysql/2024-02-14-170000_add_state_to_sso_nonce/up.sql @@ -0,0 +1,8 @@ +DROP TABLE IF EXISTS sso_nonce; + +CREATE TABLE sso_nonce ( + state VARCHAR(512) NOT NULL PRIMARY KEY, + nonce TEXT NOT NULL, + redirect_uri TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT now() +); diff --git a/migrations/mysql/2024-02-26-170000_add_pkce_to_sso_nonce/down.sql b/migrations/mysql/2024-02-26-170000_add_pkce_to_sso_nonce/down.sql new file mode 100644 index 00000000..c033f7cb --- /dev/null +++ b/migrations/mysql/2024-02-26-170000_add_pkce_to_sso_nonce/down.sql @@ -0,0 +1,8 @@ +DROP TABLE IF EXISTS sso_nonce; + +CREATE TABLE sso_nonce ( + state VARCHAR(512) NOT NULL PRIMARY KEY, + nonce TEXT NOT NULL, + redirect_uri TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT now() +); diff --git a/migrations/mysql/2024-02-26-170000_add_pkce_to_sso_nonce/up.sql b/migrations/mysql/2024-02-26-170000_add_pkce_to_sso_nonce/up.sql new file mode 100644 index 00000000..42fb0efa --- /dev/null +++ b/migrations/mysql/2024-02-26-170000_add_pkce_to_sso_nonce/up.sql @@ -0,0 +1,9 @@ +DROP TABLE IF EXISTS sso_nonce; + +CREATE TABLE sso_nonce ( + state VARCHAR(512) NOT NULL PRIMARY KEY, + nonce TEXT NOT NULL, + verifier TEXT, + redirect_uri TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT now() +); diff --git a/migrations/mysql/2024-03-06-170000_add_sso_users/down.sql b/migrations/mysql/2024-03-06-170000_add_sso_users/down.sql new file mode 100644 index 00000000..f2f92f68 --- /dev/null +++ b/migrations/mysql/2024-03-06-170000_add_sso_users/down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS sso_users; diff --git a/migrations/mysql/2024-03-06-170000_add_sso_users/up.sql b/migrations/mysql/2024-03-06-170000_add_sso_users/up.sql new file mode 100644 index 00000000..7809d43e --- /dev/null +++ b/migrations/mysql/2024-03-06-170000_add_sso_users/up.sql @@ -0,0 +1,7 @@ +CREATE TABLE sso_users ( + user_uuid CHAR(36) NOT NULL PRIMARY KEY, + identifier VARCHAR(768) NOT NULL UNIQUE, + created_at TIMESTAMP NOT NULL DEFAULT now(), + + FOREIGN KEY(user_uuid) REFERENCES users(uuid) +); diff --git a/migrations/mysql/2024-03-13-170000_sso_users_cascade/down.sql b/migrations/mysql/2024-03-13-170000_sso_users_cascade/down.sql new file mode 100644 index 00000000..e69de29b diff --git a/migrations/mysql/2024-03-13-170000_sso_users_cascade/up.sql b/migrations/mysql/2024-03-13-170000_sso_users_cascade/up.sql new file mode 100644 index 00000000..4e06fe58 --- /dev/null +++ b/migrations/mysql/2024-03-13-170000_sso_users_cascade/up.sql @@ -0,0 +1,2 @@ +ALTER TABLE sso_users DROP FOREIGN KEY `sso_users_ibfk_1`; +ALTER TABLE sso_users ADD FOREIGN KEY(user_uuid) REFERENCES users(uuid) ON UPDATE CASCADE ON DELETE CASCADE; diff --git a/migrations/postgresql/2023-09-10-133000_add_sso/down.sql b/migrations/postgresql/2023-09-10-133000_add_sso/down.sql new file mode 100644 index 00000000..2c946dc5 --- /dev/null +++ b/migrations/postgresql/2023-09-10-133000_add_sso/down.sql @@ -0,0 +1 @@ +DROP TABLE sso_nonce; diff --git a/migrations/postgresql/2023-09-10-133000_add_sso/up.sql b/migrations/postgresql/2023-09-10-133000_add_sso/up.sql new file mode 100644 index 00000000..1321e246 --- /dev/null +++ b/migrations/postgresql/2023-09-10-133000_add_sso/up.sql @@ -0,0 +1,4 @@ +CREATE TABLE sso_nonce ( + nonce CHAR(36) NOT NULL PRIMARY KEY, + created_at TIMESTAMP NOT NULL DEFAULT now() +); diff --git a/migrations/postgresql/2023-09-14-133000_add_users_organizations_invited_by_email/down.sql b/migrations/postgresql/2023-09-14-133000_add_users_organizations_invited_by_email/down.sql new file mode 100644 index 00000000..3a708927 --- /dev/null +++ b/migrations/postgresql/2023-09-14-133000_add_users_organizations_invited_by_email/down.sql @@ -0,0 +1 @@ +ALTER TABLE users_organizations DROP COLUMN invited_by_email; diff --git a/migrations/postgresql/2023-09-14-133000_add_users_organizations_invited_by_email/up.sql b/migrations/postgresql/2023-09-14-133000_add_users_organizations_invited_by_email/up.sql new file mode 100644 index 00000000..c94e1131 --- /dev/null +++ b/migrations/postgresql/2023-09-14-133000_add_users_organizations_invited_by_email/up.sql @@ -0,0 +1 @@ +ALTER TABLE users_organizations ADD COLUMN invited_by_email TEXT DEFAULT NULL; diff --git a/migrations/postgresql/2024-02-14-170000_add_state_to_sso_nonce/down.sql b/migrations/postgresql/2024-02-14-170000_add_state_to_sso_nonce/down.sql new file mode 100644 index 00000000..7cf4d9d6 --- /dev/null +++ b/migrations/postgresql/2024-02-14-170000_add_state_to_sso_nonce/down.sql @@ -0,0 +1,6 @@ +DROP TABLE sso_nonce; + +CREATE TABLE sso_nonce ( + nonce CHAR(36) NOT NULL PRIMARY KEY, + created_at TIMESTAMP NOT NULL DEFAULT now() +); diff --git a/migrations/postgresql/2024-02-14-170000_add_state_to_sso_nonce/up.sql b/migrations/postgresql/2024-02-14-170000_add_state_to_sso_nonce/up.sql new file mode 100644 index 00000000..f7402460 --- /dev/null +++ b/migrations/postgresql/2024-02-14-170000_add_state_to_sso_nonce/up.sql @@ -0,0 +1,8 @@ +DROP TABLE sso_nonce; + +CREATE TABLE sso_nonce ( + state TEXT NOT NULL PRIMARY KEY, + nonce TEXT NOT NULL, + redirect_uri TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT now() +); diff --git a/migrations/postgresql/2024-02-26-170000_add_pkce_to_sso_nonce/down.sql b/migrations/postgresql/2024-02-26-170000_add_pkce_to_sso_nonce/down.sql new file mode 100644 index 00000000..ef209a45 --- /dev/null +++ b/migrations/postgresql/2024-02-26-170000_add_pkce_to_sso_nonce/down.sql @@ -0,0 +1,8 @@ +DROP TABLE IF EXISTS sso_nonce; + +CREATE TABLE sso_nonce ( + state TEXT NOT NULL PRIMARY KEY, + nonce TEXT NOT NULL, + redirect_uri TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT now() +); diff --git a/migrations/postgresql/2024-02-26-170000_add_pkce_to_sso_nonce/up.sql b/migrations/postgresql/2024-02-26-170000_add_pkce_to_sso_nonce/up.sql new file mode 100644 index 00000000..f2dedfc9 --- /dev/null +++ b/migrations/postgresql/2024-02-26-170000_add_pkce_to_sso_nonce/up.sql @@ -0,0 +1,9 @@ +DROP TABLE IF EXISTS sso_nonce; + +CREATE TABLE sso_nonce ( + state TEXT NOT NULL PRIMARY KEY, + nonce TEXT NOT NULL, + verifier TEXT, + redirect_uri TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT now() +); diff --git a/migrations/postgresql/2024-03-06-170000_add_sso_users/down.sql b/migrations/postgresql/2024-03-06-170000_add_sso_users/down.sql new file mode 100644 index 00000000..f2f92f68 --- /dev/null +++ b/migrations/postgresql/2024-03-06-170000_add_sso_users/down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS sso_users; diff --git a/migrations/postgresql/2024-03-06-170000_add_sso_users/up.sql b/migrations/postgresql/2024-03-06-170000_add_sso_users/up.sql new file mode 100644 index 00000000..b74b5728 --- /dev/null +++ b/migrations/postgresql/2024-03-06-170000_add_sso_users/up.sql @@ -0,0 +1,7 @@ +CREATE TABLE sso_users ( + user_uuid CHAR(36) NOT NULL PRIMARY KEY, + identifier TEXT NOT NULL UNIQUE, + created_at TIMESTAMP NOT NULL DEFAULT now(), + + FOREIGN KEY(user_uuid) REFERENCES users(uuid) +); diff --git a/migrations/postgresql/2024-03-13-170000_sso_users_cascade/down.sql b/migrations/postgresql/2024-03-13-170000_sso_users_cascade/down.sql new file mode 100644 index 00000000..e69de29b diff --git a/migrations/postgresql/2024-03-13-170000_sso_users_cascade/up.sql b/migrations/postgresql/2024-03-13-170000_sso_users_cascade/up.sql new file mode 100644 index 00000000..38f97b4d --- /dev/null +++ b/migrations/postgresql/2024-03-13-170000_sso_users_cascade/up.sql @@ -0,0 +1,3 @@ +ALTER TABLE sso_users + DROP CONSTRAINT "sso_users_user_uuid_fkey", + ADD CONSTRAINT "sso_users_user_uuid_fkey" FOREIGN KEY(user_uuid) REFERENCES users(uuid) ON UPDATE CASCADE ON DELETE CASCADE; diff --git a/migrations/sqlite/2023-09-10-133000_add_sso/down.sql b/migrations/sqlite/2023-09-10-133000_add_sso/down.sql new file mode 100644 index 00000000..2c946dc5 --- /dev/null +++ b/migrations/sqlite/2023-09-10-133000_add_sso/down.sql @@ -0,0 +1 @@ +DROP TABLE sso_nonce; diff --git a/migrations/sqlite/2023-09-10-133000_add_sso/up.sql b/migrations/sqlite/2023-09-10-133000_add_sso/up.sql new file mode 100644 index 00000000..518664df --- /dev/null +++ b/migrations/sqlite/2023-09-10-133000_add_sso/up.sql @@ -0,0 +1,4 @@ +CREATE TABLE sso_nonce ( + nonce CHAR(36) NOT NULL PRIMARY KEY, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/migrations/sqlite/2023-09-14-133000_add_users_organizations_invited_by_email/down.sql b/migrations/sqlite/2023-09-14-133000_add_users_organizations_invited_by_email/down.sql new file mode 100644 index 00000000..3a708927 --- /dev/null +++ b/migrations/sqlite/2023-09-14-133000_add_users_organizations_invited_by_email/down.sql @@ -0,0 +1 @@ +ALTER TABLE users_organizations DROP COLUMN invited_by_email; diff --git a/migrations/sqlite/2023-09-14-133000_add_users_organizations_invited_by_email/up.sql b/migrations/sqlite/2023-09-14-133000_add_users_organizations_invited_by_email/up.sql new file mode 100644 index 00000000..c94e1131 --- /dev/null +++ b/migrations/sqlite/2023-09-14-133000_add_users_organizations_invited_by_email/up.sql @@ -0,0 +1 @@ +ALTER TABLE users_organizations ADD COLUMN invited_by_email TEXT DEFAULT NULL; diff --git a/migrations/sqlite/2024-02-14-170000_add_state_to_sso_nonce/down.sql b/migrations/sqlite/2024-02-14-170000_add_state_to_sso_nonce/down.sql new file mode 100644 index 00000000..3cbd4602 --- /dev/null +++ b/migrations/sqlite/2024-02-14-170000_add_state_to_sso_nonce/down.sql @@ -0,0 +1,6 @@ +DROP TABLE sso_nonce; + +CREATE TABLE sso_nonce ( + nonce CHAR(36) NOT NULL PRIMARY KEY, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/migrations/sqlite/2024-02-14-170000_add_state_to_sso_nonce/up.sql b/migrations/sqlite/2024-02-14-170000_add_state_to_sso_nonce/up.sql new file mode 100644 index 00000000..13e95fd8 --- /dev/null +++ b/migrations/sqlite/2024-02-14-170000_add_state_to_sso_nonce/up.sql @@ -0,0 +1,8 @@ +DROP TABLE sso_nonce; + +CREATE TABLE sso_nonce ( + state TEXT NOT NULL PRIMARY KEY, + nonce TEXT NOT NULL, + redirect_uri TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/migrations/sqlite/2024-02-26-170000_add_pkce_to_sso_nonce/down.sql b/migrations/sqlite/2024-02-26-170000_add_pkce_to_sso_nonce/down.sql new file mode 100644 index 00000000..e7a55bd8 --- /dev/null +++ b/migrations/sqlite/2024-02-26-170000_add_pkce_to_sso_nonce/down.sql @@ -0,0 +1,8 @@ +DROP TABLE IF EXISTS sso_nonce; + +CREATE TABLE sso_nonce ( + state TEXT NOT NULL PRIMARY KEY, + nonce TEXT NOT NULL, + redirect_uri TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/migrations/sqlite/2024-02-26-170000_add_pkce_to_sso_nonce/up.sql b/migrations/sqlite/2024-02-26-170000_add_pkce_to_sso_nonce/up.sql new file mode 100644 index 00000000..6b55e95d --- /dev/null +++ b/migrations/sqlite/2024-02-26-170000_add_pkce_to_sso_nonce/up.sql @@ -0,0 +1,9 @@ +DROP TABLE IF EXISTS sso_nonce; + +CREATE TABLE sso_nonce ( + state TEXT NOT NULL PRIMARY KEY, + nonce TEXT NOT NULL, + verifier TEXT, + redirect_uri TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/migrations/sqlite/2024-03-06-170000_add_sso_users/down.sql b/migrations/sqlite/2024-03-06-170000_add_sso_users/down.sql new file mode 100644 index 00000000..f2f92f68 --- /dev/null +++ b/migrations/sqlite/2024-03-06-170000_add_sso_users/down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS sso_users; diff --git a/migrations/sqlite/2024-03-06-170000_add_sso_users/up.sql b/migrations/sqlite/2024-03-06-170000_add_sso_users/up.sql new file mode 100644 index 00000000..6d015f04 --- /dev/null +++ b/migrations/sqlite/2024-03-06-170000_add_sso_users/up.sql @@ -0,0 +1,7 @@ +CREATE TABLE sso_users ( + user_uuid CHAR(36) NOT NULL PRIMARY KEY, + identifier TEXT NOT NULL UNIQUE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY(user_uuid) REFERENCES users(uuid) +); diff --git a/migrations/sqlite/2024-03-13_170000_sso_userscascade/down.sql b/migrations/sqlite/2024-03-13_170000_sso_userscascade/down.sql new file mode 100644 index 00000000..e69de29b diff --git a/migrations/sqlite/2024-03-13_170000_sso_userscascade/up.sql b/migrations/sqlite/2024-03-13_170000_sso_userscascade/up.sql new file mode 100644 index 00000000..53b09cf4 --- /dev/null +++ b/migrations/sqlite/2024-03-13_170000_sso_userscascade/up.sql @@ -0,0 +1,9 @@ +DROP TABLE IF EXISTS sso_users; + +CREATE TABLE sso_users ( + user_uuid CHAR(36) NOT NULL PRIMARY KEY, + identifier TEXT NOT NULL UNIQUE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY(user_uuid) REFERENCES users(uuid) ON UPDATE CASCADE ON DELETE CASCADE +); diff --git a/playwright/.env.template b/playwright/.env.template new file mode 100644 index 00000000..fd007b52 --- /dev/null +++ b/playwright/.env.template @@ -0,0 +1,64 @@ +################################# +### Conf to run dev instances ### +################################# +ENV=dev +DC_ENV_FILE=.env +COMPOSE_IGNORE_ORPHANS=True +DOCKER_BUILDKIT=1 + +################ +# Users Config # +################ +TEST_USER=test +TEST_USER_PASSWORD=${TEST_USER} +TEST_USER_MAIL=${TEST_USER}@yopmail.com + +TEST_USER2=test2 +TEST_USER2_PASSWORD=${TEST_USER2} +TEST_USER2_MAIL=${TEST_USER2}@yopmail.com + +TEST_USER3=test3 +TEST_USER3_PASSWORD=${TEST_USER3} +TEST_USER3_MAIL=${TEST_USER3}@yopmail.com + +################### +# Keycloak Config # +################### +KEYCLOAK_ADMIN=admin +KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN} +KC_HTTP_HOST=127.0.0.1 +KC_HTTP_PORT=8080 + +# Script parameters (use Keycloak and Vaultwarden config too) +TEST_REALM=test +DUMMY_REALM=dummy +DUMMY_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${DUMMY_REALM} + +###################### +# Vaultwarden Config # +###################### +ROCKET_ADDRESS=0.0.0.0 +ROCKET_PORT=8000 +DOMAIN=http://127.0.0.1:${ROCKET_PORT} +LOG_LEVEL=info,oidcwarden::sso=debug +I_REALLY_WANT_VOLATILE_STORAGE=true + +SSO_ENABLED=true +SSO_ONLY=false +SSO_CLIENT_ID=warden +SSO_CLIENT_SECRET=warden +SSO_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${TEST_REALM} + +SMTP_HOST=127.0.0.1 +SMTP_PORT=1025 +SMTP_SECURITY=off +SMTP_TIMEOUT=5 +SMTP_FROM=vaultwarden@test +SMTP_FROM_NAME=Vaultwarden + +######################################################## +# DUMMY values for docker-compose to stop bothering us # +######################################################## +MARIADB_PORT=3305 +MYSQL_PORT=3307 +POSTGRES_PORT=5432 diff --git a/playwright/.gitignore b/playwright/.gitignore new file mode 100644 index 00000000..8746d597 --- /dev/null +++ b/playwright/.gitignore @@ -0,0 +1,6 @@ +logs +node_modules/ +/test-results/ +/playwright-report/ +/playwright/.cache/ +temp diff --git a/playwright/README.md b/playwright/README.md new file mode 100644 index 00000000..3f8bef40 --- /dev/null +++ b/playwright/README.md @@ -0,0 +1,166 @@ +# Integration tests + +This allows running integration tests using [Playwright](https://playwright.dev/). +\ +It usse its own [test.env](/test/scenarios/test.env) with different ports to not collide with a running dev instance. + +## Install + +This rely on `docker` and the `compose` [plugin](https://docs.docker.com/compose/install/). +Databases (`Mariadb`, `Mysql` and `Postgres`) and `Playwright` will run in containers. + +### Running Playwright outside docker + +It's possible to run `Playwright` outside of the container, this remove the need to rebuild the image for each change. +You'll additionally need `nodejs` then run: + +```bash +npm install +npx playwright install-deps +npx playwright install firefox +``` + +## Usage + +To run all the tests: + +```bash +DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright +``` + +To force a rebuild of the Playwright image: +```bash +DOCKER_BUILDKIT=1 docker compose --env-file test.env build Playwright +``` + +To access the ui to easily run test individually and debug if needed (will not work in docker): + +```bash +npx playwright test --ui +``` + +### DB + +Projects are configured to allow to run tests only on specific database. +\ +You can use: + +```bash +DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=mariadb +DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=mysql +DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=postgres +DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite +``` + +### SSO + +To run the SSO tests: + +```bash +DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project sso-sqlite +``` + +### Keep services running + +If you want you can keep the Db and Keycloak runnning (states are not impacted by the tests): + +```bash +PW_KEEP_SERVICE_RUNNNING=true npx playwright test +``` + +### Running specific tests + +To run a whole file you can : + +```bash +DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite tests/login.spec.ts +DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite login +``` + +To run only a specifc test (It might fail if it has dependency): + +```bash +DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite -g "Account creation" +DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite tests/login.spec.ts:16 +``` + +## Writing scenario + +When creating new scenario use the recorder to more easily identify elements (in general try to rely on visible hint to identify elements and not hidden ids). +This does not start the server, you will need to start it manually. + +```bash +npx playwright codegen "http://127.0.0.1:8000" +``` + +## Override web-vault + +It's possible to change the `web-vault` used by referencing a different `bw_web_builds` commit. + +```bash +export PW_WV_REPO_URL=https://github.com/Timshel/oidc_web_builds.git +export PW_WV_COMMIT_HASH=8707dc76df3f0cceef2be5bfae37bb29bd17fae6 +DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env build Playwright +``` + +# OpenID Connect test setup + +Additionnaly this `docker-compose` template allow to run locally `VaultWarden`, [Keycloak](https://www.keycloak.org/) and [Maildev](https://github.com/timshel/maildev) to test OIDC. + +## Setup + +This rely on `docker` and the `compose` [plugin](https://docs.docker.com/compose/install/). +First create a copy of `.env.template` as `.env` (This is done to prevent commiting your custom settings, Ex `SMTP_`). + +## Usage + +Then start the stack (the `profile` is required to run `Vaultwarden`) : + +```bash +> docker compose --profile vaultwarden --env-file .env up +.... +keycloakSetup_1 | Logging into http://127.0.0.1:8080 as user admin of realm master +keycloakSetup_1 | Created new realm with id 'test' +keycloakSetup_1 | 74af4933-e386-4e64-ba15-a7b61212c45e +oidc_keycloakSetup_1 exited with code 0 +``` + +Wait until `oidc_keycloakSetup_1 exited with code 0` which indicate the correct setup of the Keycloak realm, client and user (It's normal for this container to stop once the configuration is done). + +Then you can access : + +- `VaultWarden` on http://0.0.0.0:8000 with the default user `test@yopmail.com/test`. +- `Keycloak` on http://0.0.0.0:8080/admin/master/console/ with the default user `admin/admin` +- `Maildev` on http://0.0.0.0:1080 + +To proceed with an SSO login after you enter the email, on the screen prompting for `Master Password` the SSO button should be visible. +To use your computer external ip (for example when testing with a phone) you will have to configure `KC_HTTP_HOST` and `DOMAIN`. + +## Running only Keycloak + +You can run just `Keycloak` with `--profile keycloak`: + +```bash +> docker compose --profile keycloak --env-file .env up +``` +When running with a local VaultWarden, you can use a front-end build from [dani-garcia/bw_web_builds](https://github.com/dani-garcia/bw_web_builds/releases). + +## Rebuilding the Vaultwarden + +To force rebuilding the Vaultwarden image you can run + +```bash +docker compose --profile vaultwarden --env-file .env build VaultwardenPrebuild Vaultwarden +``` + +## Configuration + +All configuration for `keycloak` / `VaultWarden` / `keycloak_setup.sh` can be found in [.env](.env.template). +The content of the file will be loaded as environment variables in all containers. + +- `keycloak` [configuration](https://www.keycloak.org/server/all-config) include `KEYCLOAK_ADMIN` / `KEYCLOAK_ADMIN_PASSWORD` and any variable prefixed `KC_` ([more information](https://www.keycloak.org/server/configuration#_example_configuring_the_db_url_host_parameter)). +- All `VaultWarden` configuration can be set (EX: `SMTP_*`) + +## Cleanup + +Use `docker compose --profile vaultWarden down`. diff --git a/playwright/compose/keycloak/Dockerfile b/playwright/compose/keycloak/Dockerfile new file mode 100644 index 00000000..35888950 --- /dev/null +++ b/playwright/compose/keycloak/Dockerfile @@ -0,0 +1,40 @@ +FROM docker.io/library/debian:bookworm-slim as build + +ENV DEBIAN_FRONTEND=noninteractive +ARG KEYCLOAK_VERSION + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +RUN apt-get update \ + && apt-get install -y ca-certificates curl wget \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR / + +RUN wget -c https://github.com/keycloak/keycloak/releases/download/${KEYCLOAK_VERSION}/keycloak-${KEYCLOAK_VERSION}.tar.gz -O - | tar -xz + +FROM docker.io/library/debian:bookworm-slim + +ENV DEBIAN_FRONTEND=noninteractive +ARG KEYCLOAK_VERSION + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +RUN apt-get update \ + && apt-get install -y ca-certificates curl wget \ + && rm -rf /var/lib/apt/lists/* + +ARG JAVA_URL +ARG JAVA_VERSION + +ENV JAVA_VERSION=${JAVA_VERSION} + +RUN mkdir -p /opt/openjdk && cd /opt/openjdk \ + && wget -c "${JAVA_URL}" -O - | tar -xz + +WORKDIR / + +COPY setup.sh /setup.sh +COPY --from=build /keycloak-${KEYCLOAK_VERSION}/bin /opt/keycloak/bin + +CMD "/setup.sh" diff --git a/playwright/compose/keycloak/setup.sh b/playwright/compose/keycloak/setup.sh new file mode 100755 index 00000000..36597b1d --- /dev/null +++ b/playwright/compose/keycloak/setup.sh @@ -0,0 +1,36 @@ +#!/bin/bash + +export PATH=/opt/keycloak/bin:/opt/openjdk/jdk-${JAVA_VERSION}/bin:$PATH +export JAVA_HOME=/opt/openjdk/jdk-${JAVA_VERSION} + +STATUS_CODE=0 +while [[ "$STATUS_CODE" != "404" ]] ; do + echo "Will retry in 2 seconds" + sleep 2 + + STATUS_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$DUMMY_AUTHORITY") + + if [[ "$STATUS_CODE" = "200" ]]; then + echo "Setup should already be done. Will not run." + exit 0 + fi +done + +set -e + +kcadm.sh config credentials --server "http://${KC_HTTP_HOST}:${KC_HTTP_PORT}" --realm master --user "$KEYCLOAK_ADMIN" --password "$KEYCLOAK_ADMIN_PASSWORD" --client admin-cli + +kcadm.sh create realms -s realm="$TEST_REALM" -s enabled=true -s "accessTokenLifespan=600" +kcadm.sh create clients -r test -s "clientId=$SSO_CLIENT_ID" -s "secret=$SSO_CLIENT_SECRET" -s "redirectUris=[\"$DOMAIN/*\"]" -i + +TEST_USER_ID=$(kcadm.sh create users -r "$TEST_REALM" -s "username=$TEST_USER" -s "firstName=$TEST_USER" -s "lastName=$TEST_USER" -s "email=$TEST_USER_MAIL" -s emailVerified=true -s enabled=true -i) +kcadm.sh update users/$TEST_USER_ID/reset-password -r "$TEST_REALM" -s type=password -s "value=$TEST_USER_PASSWORD" -n + +TEST_USER2_ID=$(kcadm.sh create users -r "$TEST_REALM" -s "username=$TEST_USER2" -s "firstName=$TEST_USER2" -s "lastName=$TEST_USER2" -s "email=$TEST_USER2_MAIL" -s emailVerified=true -s enabled=true -i) +kcadm.sh update users/$TEST_USER2_ID/reset-password -r "$TEST_REALM" -s type=password -s "value=$TEST_USER2_PASSWORD" -n + +TEST_USER3_ID=$(kcadm.sh create users -r "$TEST_REALM" -s "username=$TEST_USER3" -s "firstName=$TEST_USER3" -s "lastName=$TEST_USER3" -s "email=$TEST_USER3_MAIL" -s emailVerified=true -s enabled=true -i) +kcadm.sh update users/$TEST_USER3_ID/reset-password -r "$TEST_REALM" -s type=password -s "value=$TEST_USER3_PASSWORD" -n + +# Dummy realm to mark end of setup +kcadm.sh create realms -s realm="$DUMMY_REALM" -s enabled=true -s "accessTokenLifespan=600" diff --git a/playwright/compose/playwright/Dockerfile b/playwright/compose/playwright/Dockerfile new file mode 100644 index 00000000..1a4b1ddb --- /dev/null +++ b/playwright/compose/playwright/Dockerfile @@ -0,0 +1,40 @@ +FROM docker.io/library/debian:bookworm-slim + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update \ + && apt-get install -y ca-certificates curl \ + && curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc \ + && chmod a+r /etc/apt/keyrings/docker.asc \ + && echo "deb [signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable" | tee /etc/apt/sources.list.d/docker.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + containerd.io \ + docker-buildx-plugin \ + docker-ce \ + docker-ce-cli \ + docker-compose-plugin \ + git \ + libmariadb-dev-compat \ + libpq5 \ + nodejs \ + npm \ + openssl \ + && rm -rf /var/lib/apt/lists/* + +RUN mkdir /playwright +WORKDIR /playwright + +COPY package.json . +RUN npm install && npx playwright install-deps && npx playwright install firefox + +COPY docker-compose.yml test.env ./ +COPY compose ./compose + +COPY *.ts test.env ./ +COPY tests ./tests + +ENTRYPOINT ["/usr/bin/npx", "playwright"] +CMD ["test"] diff --git a/playwright/compose/warden/Dockerfile b/playwright/compose/warden/Dockerfile new file mode 100644 index 00000000..93d12b3b --- /dev/null +++ b/playwright/compose/warden/Dockerfile @@ -0,0 +1,40 @@ +FROM playwright_oidc_vaultwarden_prebuilt AS prebuilt + +FROM node:18-bookworm AS build + +ARG REPO_URL +ARG COMMIT_HASH + +ENV REPO_URL=$REPO_URL +ENV COMMIT_HASH=$COMMIT_HASH + +COPY --from=prebuilt /web-vault /web-vault + +COPY build.sh /build.sh +RUN /build.sh + +######################## RUNTIME IMAGE ######################## +FROM docker.io/library/debian:bookworm-slim + +ENV DEBIAN_FRONTEND=noninteractive + +# Create data folder and Install needed libraries +RUN mkdir /data && \ + apt-get update && apt-get install -y \ + --no-install-recommends \ + ca-certificates \ + curl \ + libmariadb-dev-compat \ + libpq5 \ + openssl && \ + rm -rf /var/lib/apt/lists/* + +# Copies the files from the context (Rocket.toml file and web-vault) +# and the binary from the "build" stage to the current stage +WORKDIR / + +COPY --from=prebuilt /start.sh . +COPY --from=prebuilt /vaultwarden . +COPY --from=build /web-vault ./web-vault + +ENTRYPOINT ["/start.sh"] diff --git a/playwright/compose/warden/build.sh b/playwright/compose/warden/build.sh new file mode 100755 index 00000000..da354112 --- /dev/null +++ b/playwright/compose/warden/build.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +echo $REPO_URL +echo $COMMIT_HASH + +if [[ ! -z "$REPO_URL" ]] && [[ ! -z "$COMMIT_HASH" ]] ; then + rm -rf /web-vault + + mkdir bw_web_builds; + cd bw_web_builds; + + git -c init.defaultBranch=main init + git remote add origin "$REPO_URL" + git fetch --depth 1 origin "$COMMIT_HASH" + git -c advice.detachedHead=false checkout FETCH_HEAD + + export VAULT_VERSION=$(cat Dockerfile | grep "ARG VAULT_VERSION" | cut -d "=" -f2) + ./scripts/checkout_web_vault.sh + ./scripts/patch_web_vault.sh + ./scripts/build_web_vault.sh + printf '{"version":"%s"}' "$COMMIT_HASH" > ./web-vault/apps/web/build/vw-version.json + + mv ./web-vault/apps/web/build /web-vault +fi diff --git a/playwright/docker-compose.yml b/playwright/docker-compose.yml new file mode 100644 index 00000000..3e56477c --- /dev/null +++ b/playwright/docker-compose.yml @@ -0,0 +1,124 @@ +services: + VaultwardenPrebuild: + profiles: ["playwright", "vaultwarden"] + container_name: playwright_oidc_vaultwarden_prebuilt + image: playwright_oidc_vaultwarden_prebuilt + build: + context: .. + dockerfile: Dockerfile + entrypoint: /bin/bash + restart: "no" + + Vaultwarden: + profiles: ["playwright", "vaultwarden"] + container_name: playwright_oidc_vaultwarden-${ENV:-dev} + image: playwright_oidc_vaultwarden-${ENV:-dev} + network_mode: "host" + build: + context: compose/warden + dockerfile: Dockerfile + args: + REPO_URL: ${PW_WV_REPO_URL:-} + COMMIT_HASH: ${PW_WV_COMMIT_HASH:-} + env_file: ${DC_ENV_FILE:-.env} + environment: + - DATABASE_URL + - I_REALLY_WANT_VOLATILE_STORAGE + - LOG_LEVEL + - LOGIN_RATELIMIT_MAX_BURST + - SMTP_HOST + - SMTP_FROM + - SMTP_DEBUG + - SSO_DEBUG_TOKENS + - SSO_FRONTEND + - SSO_ENABLED + - SSO_ONLY + restart: "no" + depends_on: + - VaultwardenPrebuild + + Playwright: + profiles: ["playwright"] + container_name: playwright_oidc_playwright + image: playwright_oidc_playwright + network_mode: "host" + build: + context: . + dockerfile: compose/playwright/Dockerfile + environment: + - PW_WV_REPO_URL + - PW_WV_COMMIT_HASH + restart: "no" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ..:/project + + Mariadb: + profiles: ["playwright"] + container_name: playwright_mariadb + image: mariadb:11.2.4 + env_file: test.env + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + start_period: 10s + interval: 10s + ports: + - ${MARIADB_PORT}:3306 + + Mysql: + profiles: ["playwright"] + container_name: playwright_mysql + image: mysql:8.4.1 + env_file: test.env + healthcheck: + test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"] + start_period: 10s + interval: 10s + ports: + - ${MYSQL_PORT}:3306 + + Postgres: + profiles: ["playwright"] + container_name: playwright_postgres + image: postgres:16.3 + env_file: test.env + healthcheck: + test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"] + start_period: 20s + interval: 30s + ports: + - ${POSTGRES_PORT}:5432 + + Maildev: + profiles: ["vaultwarden", "maildev"] + container_name: maildev + image: timshel/maildev:3.0.4 + ports: + - ${SMTP_PORT}:1025 + - 1080:1080 + + Keycloak: + profiles: ["keycloak", "vaultwarden"] + container_name: keycloak-${ENV:-dev} + image: quay.io/keycloak/keycloak:25.0.4 + network_mode: "host" + command: + - start-dev + env_file: ${DC_ENV_FILE:-.env} + + KeycloakSetup: + profiles: ["keycloak", "vaultwarden"] + container_name: keycloakSetup-${ENV:-dev} + image: keycloak_setup-${ENV:-dev} + build: + context: compose/keycloak + dockerfile: Dockerfile + args: + KEYCLOAK_VERSION: 25.0.4 + JAVA_URL: https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_linux-x64_bin.tar.gz + JAVA_VERSION: 21.0.2 + network_mode: "host" + depends_on: + - Keycloak + restart: "no" + env_file: ${DC_ENV_FILE:-.env} diff --git a/playwright/global-setup.ts b/playwright/global-setup.ts new file mode 100644 index 00000000..89405f12 --- /dev/null +++ b/playwright/global-setup.ts @@ -0,0 +1,22 @@ +import { firefox, type FullConfig } from '@playwright/test'; +import { execSync } from 'node:child_process'; +import fs from 'fs'; + +const utils = require('./global-utils'); + +utils.loadEnv(); + +async function globalSetup(config: FullConfig) { + // Are we running in docker and the project is mounted ? + const path = (fs.existsSync("/project/playwright/playwright.config.ts") ? "/project/playwright" : "."); + execSync(`docker compose --project-directory ${path} --profile playwright --env-file test.env build VaultwardenPrebuild`, { + env: { ...process.env }, + stdio: "inherit" + }); + execSync(`docker compose --project-directory ${path} --profile playwright --env-file test.env build Vaultwarden`, { + env: { ...process.env }, + stdio: "inherit" + }); +} + +export default globalSetup; diff --git a/playwright/global-utils.ts b/playwright/global-utils.ts new file mode 100644 index 00000000..e9d8008f --- /dev/null +++ b/playwright/global-utils.ts @@ -0,0 +1,246 @@ +import { expect, type Browser, type TestInfo } from '@playwright/test'; +import { EventEmitter } from "events"; +import { type Mail, MailServer } from 'maildev'; +import { execSync } from 'node:child_process'; + +import dotenv from 'dotenv'; +import dotenvExpand from 'dotenv-expand'; + +const fs = require("fs"); +const { spawn } = require('node:child_process'); + +export function loadEnv(){ + var myEnv = dotenv.config({ path: 'test.env' }); + dotenvExpand.expand(myEnv); + + return { + user1: { + email: process.env.TEST_USER_MAIL, + name: process.env.TEST_USER, + password: process.env.TEST_USER_PASSWORD, + }, + user2: { + email: process.env.TEST_USER2_MAIL, + name: process.env.TEST_USER2, + password: process.env.TEST_USER2_PASSWORD, + }, + user3: { + email: process.env.TEST_USER3_MAIL, + name: process.env.TEST_USER3, + password: process.env.TEST_USER3_PASSWORD, + }, + } +} + +export async function waitFor(url: String, browser: Browser) { + var ready = false; + var context; + + do { + try { + context = await browser.newContext(); + const page = await context.newPage(); + await page.waitForTimeout(500); + const result = await page.goto(url); + ready = result.status() === 200; + } catch(e) { + if( !e.message.includes("CONNECTION_REFUSED") ){ + throw e; + } + } finally { + await context.close(); + } + } while(!ready); +} + +export function startComposeService(serviceName: String){ + console.log(`Starting ${serviceName}`); + execSync(`docker compose --profile playwright --env-file test.env up -d ${serviceName}`); +} + +export function stopComposeService(serviceName: String){ + console.log(`Stopping ${serviceName}`); + execSync(`docker compose --profile playwright --env-file test.env stop ${serviceName}`); +} + +function wipeSqlite(){ + console.log(`Delete Vaultwarden container to wipe sqlite`); + execSync(`docker compose --env-file test.env stop Vaultwarden`); + execSync(`docker compose --env-file test.env rm -f Vaultwarden`); +} + +async function wipeMariaDB(){ + var mysql = require('mysql2/promise'); + var ready = false; + var connection; + + do { + try { + connection = await mysql.createConnection({ + user: process.env.MARIADB_USER, + host: "127.0.0.1", + database: process.env.MARIADB_DATABASE, + password: process.env.MARIADB_PASSWORD, + port: process.env.MARIADB_PORT, + }); + + await connection.execute(`DROP DATABASE ${process.env.MARIADB_DATABASE}`); + await connection.execute(`CREATE DATABASE ${process.env.MARIADB_DATABASE}`); + console.log('Successfully wiped mariadb'); + ready = true; + } catch (err) { + console.log(`Error when wiping mariadb: ${err}`); + } finally { + if( connection ){ + connection.end(); + } + } + await new Promise(r => setTimeout(r, 1000)); + } while(!ready); +} + +async function wipeMysqlDB(){ + var mysql = require('mysql2/promise'); + var ready = false; + var connection; + + do{ + try { + connection = await mysql.createConnection({ + user: process.env.MYSQL_USER, + host: "127.0.0.1", + database: process.env.MYSQL_DATABASE, + password: process.env.MYSQL_PASSWORD, + port: process.env.MYSQL_PORT, + }); + + await connection.execute(`DROP DATABASE ${process.env.MYSQL_DATABASE}`); + await connection.execute(`CREATE DATABASE ${process.env.MYSQL_DATABASE}`); + console.log('Successfully wiped mysql'); + ready = true; + } catch (err) { + console.log(`Error when wiping mysql: ${err}`); + } finally { + if( connection ){ + connection.end(); + } + } + await new Promise(r => setTimeout(r, 1000)); + } while(!ready); +} + +async function wipePostgres(){ + const { Client } = require('pg'); + + const client = new Client({ + user: process.env.POSTGRES_USER, + host: "127.0.0.1", + database: "postgres", + password: process.env.POSTGRES_PASSWORD, + port: process.env.POSTGRES_PORT, + }); + + try { + await client.connect(); + await client.query(`DROP DATABASE ${process.env.POSTGRES_DB}`); + await client.query(`CREATE DATABASE ${process.env.POSTGRES_DB}`); + console.log('Successfully wiped postgres'); + } catch (err) { + console.log(`Error when wiping postgres: ${err}`); + } finally { + client.end(); + } +} + +function dbConfig(testInfo: TestInfo){ + switch(testInfo.project.name) { + case "postgres": + case "sso-postgres": + return { DATABASE_URL: `postgresql://${process.env.POSTGRES_USER}:${process.env.POSTGRES_PASSWORD}@127.0.0.1:${process.env.POSTGRES_PORT}/${process.env.POSTGRES_DB}` }; + case "mariadb": + case "sso-mariadb": + return { DATABASE_URL: `mysql://${process.env.MARIADB_USER}:${process.env.MARIADB_PASSWORD}@127.0.0.1:${process.env.MARIADB_PORT}/${process.env.MARIADB_DATABASE}` }; + case "mysql": + case "sso-mysql": + return { DATABASE_URL: `mysql://${process.env.MYSQL_USER}:${process.env.MYSQL_PASSWORD}@127.0.0.1:${process.env.MYSQL_PORT}/${process.env.MYSQL_DATABASE}`}; + case "sqlite": + case "sso-sqlite": + return { I_REALLY_WANT_VOLATILE_STORAGE: true }; + default: + throw new Error(`Unknow database name: ${testInfo.project.name}`); + } +} + +/** + * All parameters passed in `env` need to be added to the docker-compose.yml + **/ +export async function startVault(browser: Browser, testInfo: TestInfo, env = {}, resetDB: Boolean = true) { + if( resetDB ){ + switch(testInfo.project.name) { + case "postgres": + case "sso-postgres": + await wipePostgres(); + break; + case "mariadb": + case "sso-mariadb": + await wipeMariaDB(); + break; + case "mysql": + case "sso-mysql": + await wipeMysqlDB(); + break; + case "sqlite": + case "sso-sqlite": + wipeSqlite(); + break; + default: + throw new Error(`Unknow database name: ${testInfo.project.name}`); + } + } + + console.log(`Starting Vaultwarden`); + execSync(`docker compose --profile playwright --env-file test.env up -d Vaultwarden`, { + env: { ...env, ...dbConfig(testInfo) }, + }); + await waitFor("/", browser); + console.log(`Vaultwarden running on: ${process.env.DOMAIN}`); +} + +export async function stopVault(force: boolean = false) { + if( force === false && process.env.PW_KEEP_SERVICE_RUNNNING === "true" ) { + console.log(`Keep vaultwarden running on: ${process.env.DOMAIN}`); + } else { + console.log(`Vaultwarden stopping`); + execSync(`docker compose --profile playwright --env-file test.env stop Vaultwarden`); + } +} + +export async function restartVault(page: Page, testInfo: TestInfo, env, resetDB: Boolean = true) { + stopVault(true); + return startVault(page.context().browser(), testInfo, env, resetDB); +} + +export async function checkNotification(page: Page, hasText: string) { + await expect(page.locator('bit-toast').filter({ hasText })).toBeVisible(); + await page.locator('bit-toast').filter({ hasText }).getByRole('button').click(); + await expect(page.locator('bit-toast').filter({ hasText })).toHaveCount(0); +} + +export async function cleanLanding(page: Page) { + await page.goto('/', { waitUntil: 'domcontentloaded' }); + await expect(page.getByRole('button').nth(0)).toBeVisible(); + + const logged = await page.getByRole('button', { name: 'Log out' }).count(); + if( logged > 0 ){ + await page.getByRole('button', { name: 'Log out' }).click(); + await page.getByRole('button', { name: 'Log out' }).click(); + } +} + +export async function logout(test: Test, page: Page, user: { name: string }) { + await test.step('logout', async () => { + await page.getByRole('button', { name: user.name, exact: true }).click(); + await page.getByRole('menuitem', { name: 'Log out' }).click(); + await expect(page.getByRole('heading', { name: 'Log in' })).toBeVisible(); + }); +} diff --git a/playwright/package-lock.json b/playwright/package-lock.json new file mode 100644 index 00000000..fbe1b39a --- /dev/null +++ b/playwright/package-lock.json @@ -0,0 +1,2547 @@ +{ + "name": "scenarios", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "scenarios", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "mysql2": "^3.14.1", + "otpauth": "^9.4.0", + "pg": "^8.16.0" + }, + "devDependencies": { + "@playwright/test": "^1.53.0", + "dotenv": "^16.5.0", + "dotenv-expand": "^12.0.2", + "maildev": "npm:@timshel_npm/maildev@^3.1.2" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.2.tgz", + "integrity": "sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.0.10.tgz", + "integrity": "sha512-TiJ5Ajr6WRd1r8HSiwJvZBiJOqtH86aHpUjq5aEKWHiII2Qfjqd/HCWKPOW8EP4vcspXbHnXrwIDlu5savQipg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "@csstools/color-helpers": "^5.0.2", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@noble/hashes": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.1.tgz", + "integrity": "sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ==", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@playwright/test": { + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.0.tgz", + "integrity": "sha512-15hjKreZDcp7t6TL/7jkAo6Df5STZN09jGiv5dbP9A6vMVncXRqE7/B2SncsyOwrkZRBH2i6/TPOL8BVmm3c7w==", + "dev": true, + "dependencies": { + "playwright": "1.53.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@selderee/plugin-htmlparser2": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz", + "integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==", + "dev": true, + "dependencies": { + "domhandler": "^5.0.3", + "selderee": "^0.11.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "dev": true + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/mailparser": { + "version": "3.4.6", + "resolved": "https://registry.npmjs.org/@types/mailparser/-/mailparser-3.4.6.tgz", + "integrity": "sha512-wVV3cnIKzxTffaPH8iRnddX1zahbYB1ZEoAxyhoBo3TBCBuK6nZ8M8JYO/RhsCuuBVOw/DEN/t/ENbruwlxn6Q==", + "dev": true, + "dependencies": { + "@types/node": "*", + "iconv-lite": "^0.6.3" + } + }, + "node_modules/@types/node": { + "version": "24.0.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.1.tgz", + "integrity": "sha512-MX4Zioh39chHlDJbKmEgydJDS3tspMP/lnQC67G3SWsTnb9NeYVWOjkxpOSy4oMfPs4StcWHwBrvUb4ybfnuaw==", + "dev": true, + "dependencies": { + "undici-types": "~7.8.0" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "optional": true + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dev": true, + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/addressparser": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/addressparser/-/addressparser-1.0.1.tgz", + "integrity": "sha512-aQX7AISOMM7HFE0iZ3+YnD07oIeJqWGVnJ+ZIKaBZAk03ftmVYVqsGas/rbXKR21n4D/hKCSHypvcyOkds/xzg==", + "dev": true + }, + "node_modules/agent-base": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", + "dev": true, + "engines": { + "node": ">= 14" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "dev": true + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/base32.js": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.1.0.tgz", + "integrity": "sha512-n3TkB02ixgBOhTvANakDb4xaMXnYUVkNoRFJjQflcqMQhyEKxEHdj3E6N8t8sUQ0mjH/3/JxzlXuz3ul/J90pQ==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "dev": true, + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "dev": true, + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "dev": true, + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.0.tgz", + "integrity": "sha512-k6WLKfunuqCYD3t6AsuPGvQWaKwuLLh2/xHNcX4qE+vIfDNXpSqnrhwA7O53R7WVQUnt8dVAIW+YHr7xTgOgGA==", + "dev": true, + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.0.2", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dev": true, + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "dev": true + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "dev": true, + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/cssstyle": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.4.0.tgz", + "integrity": "sha512-W0Y2HOXlPkb2yaKrCVRjinYKciu/qSLEmK0K9mcfDei3zwlnHFEHAs/Du3cIRwPqY+J4JsiBzUjoHyc8RsJ03A==", + "dev": true, + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decimal.js": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz", + "integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==", + "dev": true + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "dev": true, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ] + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/dompurify": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz", + "integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==", + "dev": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dotenv": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", + "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.2.tgz", + "integrity": "sha512-lXpXz2ZE1cea1gL4sz2Ipj8y4PiVjytYr3Ij0SWoms1PGxIv7m2CRKuRuCRtHdVuvM/hNJPMxt5PbhboNC4dPQ==", + "dev": true, + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/encoding-japanese": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz", + "integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==", + "dev": true, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/engine.io": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.4.tgz", + "integrity": "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g==", + "dev": true, + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.17.1" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "dev": true, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/engine.io/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/engine.io/node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "dev": true, + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "dev": true, + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/form-data": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz", + "integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==", + "dev": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "bin": { + "he": "bin/he" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/html-to-text": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz", + "integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==", + "dev": true, + "dependencies": { + "@selderee/plugin-htmlparser2": "^0.11.0", + "deepmerge": "^4.3.1", + "dom-serializer": "^2.0.0", + "htmlparser2": "^8.0.2", + "selderee": "^0.11.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dev": true, + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/http-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/ipv6-normalize": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ipv6-normalize/-/ipv6-normalize-1.0.1.tgz", + "integrity": "sha512-Bm6H79i01DjgGTCWjUuCjJ6QDo1HB96PT/xCYuyJUP9WFbVDrLSbG4EZCvOCun2rNswZb0c3e4Jt/ws795esHA==", + "dev": true + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==" + }, + "node_modules/jsdom": { + "version": "24.1.3", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.1.3.tgz", + "integrity": "sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==", + "dev": true, + "dependencies": { + "cssstyle": "^4.0.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.4", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/leac": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz", + "integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==", + "dev": true, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, + "node_modules/libbase64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz", + "integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==", + "dev": true + }, + "node_modules/libmime": { + "version": "5.3.6", + "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.6.tgz", + "integrity": "sha512-j9mBC7eiqi6fgBPAGvKCXJKJSIASanYF4EeA4iBzSG0HxQxmXnR3KbyWqTn4CwsKSebqCv2f5XZfAO6sKzgvwA==", + "dev": true, + "dependencies": { + "encoding-japanese": "2.2.0", + "iconv-lite": "0.6.3", + "libbase64": "1.3.0", + "libqp": "2.1.1" + } + }, + "node_modules/libqp": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz", + "integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==", + "dev": true + }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "dev": true, + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true + }, + "node_modules/lru.min": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.2.tgz", + "integrity": "sha512-Nv9KddBcQSlQopmBHXSsZVY5xsdlZkdH/Iey0BlcBYggMd4two7cZnKOK9vmy3nY0O5RGH99z1PCeTpPqszUYg==", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/maildev": { + "name": "@timshel_npm/maildev", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@timshel_npm/maildev/-/maildev-3.1.2.tgz", + "integrity": "sha512-AQ6vu7g7K+x/6zFrvKGR9CfJjKNhJI8dhY1wfQr/6tbidADixN7NlY1HK0A6RGE+IW8VghWR5amWWGAabBIELw==", + "dev": true, + "dependencies": { + "@types/mailparser": "^3.4.6", + "addressparser": "1.0.1", + "async": "^3.2.6", + "commander": "^12.1.0", + "compression": "^1.8.0", + "cors": "^2.8.5", + "dompurify": "^3.2.6", + "express": "^4.21.2", + "jsdom": "^24.1.3", + "mailparser": "^3.7.3", + "mime": "1.6.0", + "nodemailer": "^6.10.1", + "smtp-server": "^3.13.8", + "socket.io": "^4.8.1", + "wildstring": "1.0.9" + }, + "bin": { + "maildev": "bin/maildev" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/mailparser": { + "version": "3.7.3", + "resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.7.3.tgz", + "integrity": "sha512-0RM14cZF0gO1y2Q/82hhWranispZOUSYHwvQ21h12x90NwD6+D5q59S5nOLqCtCdYitHN58LJXWEHa4RWm7BYA==", + "dev": true, + "dependencies": { + "encoding-japanese": "2.2.0", + "he": "1.2.0", + "html-to-text": "9.0.5", + "iconv-lite": "0.6.3", + "libmime": "5.3.6", + "linkify-it": "5.0.0", + "mailsplit": "5.4.3", + "nodemailer": "7.0.3", + "punycode.js": "2.3.1", + "tlds": "1.259.0" + } + }, + "node_modules/mailparser/node_modules/nodemailer": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.3.tgz", + "integrity": "sha512-Ajq6Sz1x7cIK3pN6KesGTah+1gnwMnx5gKl3piQlQQE/PwyJ4Mbc8is2psWYxK3RJTVeqsDaCv8ZzXLCDHMTZw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/mailsplit": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/mailsplit/-/mailsplit-5.4.3.tgz", + "integrity": "sha512-PFV0BBh4Tv7Omui5FtXXVtN4ExAxIi8Yvmb9JgBz+J6Hnnrv/YYXLlKKudLhXwd3/qWEATOslRsnzVCWDeCnmQ==", + "dev": true, + "dependencies": { + "libbase64": "1.3.0", + "libmime": "5.3.6", + "libqp": "2.1.1" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + }, + "node_modules/mysql2": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.14.1.tgz", + "integrity": "sha512-7ytuPQJjQB8TNAYX/H2yhL+iQOnIBjAMam361R7UAL0lOVXWjtdrmoL9HYKqKoLp/8UUTRcvo1QPvK9KL7wA8w==", + "dependencies": { + "aws-ssl-profiles": "^1.1.1", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.6.3", + "long": "^5.2.1", + "lru.min": "^1.0.0", + "named-placeholders": "^1.1.3", + "seq-queue": "^0.0.5", + "sqlstring": "^2.3.2" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.3.tgz", + "integrity": "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==", + "dependencies": { + "lru-cache": "^7.14.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/named-placeholders/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nodemailer": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", + "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/nwsapi": { + "version": "2.2.20", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.20.tgz", + "integrity": "sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==", + "dev": true + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/otpauth": { + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.4.0.tgz", + "integrity": "sha512-fHIfzIG5RqCkK9cmV8WU+dPQr9/ebR5QOwGZn2JAr1RQF+lmAuLL2YdtdqvmBjNmgJlYk3KZ4a0XokaEhg1Jsw==", + "dependencies": { + "@noble/hashes": "1.7.1" + }, + "funding": { + "url": "https://github.com/hectorm/otpauth?sponsor=1" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/parseley": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz", + "integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==", + "dev": true, + "dependencies": { + "leac": "^0.6.0", + "peberminta": "^0.9.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "dev": true + }, + "node_modules/peberminta": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz", + "integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==", + "dev": true, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, + "node_modules/pg": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.0.tgz", + "integrity": "sha512-7SKfdvP8CTNXjMUzfcVTaI+TDzBEeaUnVwiVGZQD1Hh33Kpev7liQba9uLd4CfN8r9mCVsD0JIpq03+Unpz+kg==", + "dependencies": { + "pg-connection-string": "^2.9.0", + "pg-pool": "^3.10.0", + "pg-protocol": "^1.10.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 8.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.2.5" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.5.tgz", + "integrity": "sha512-OOX22Vt0vOSRrdoUPKJ8Wi2OpE/o/h9T8X1s4qSkCedbNah9ei2W2765be8iMVxQUsvgT7zIAT2eIa9fs5+vtg==", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.0.tgz", + "integrity": "sha512-P2DEBKuvh5RClafLngkAuGe9OUlFV7ebu8w1kmaaOgPcpJd1RIFh7otETfI6hAR8YupOLFTY7nuvvIn7PLciUQ==" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.0.tgz", + "integrity": "sha512-DzZ26On4sQ0KmqnO34muPcmKbhrjmyiO4lCCR0VwEd7MjmiKf5NTg/6+apUEu0NF7ESa37CGzFxH513CoUmWnA==", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.0.tgz", + "integrity": "sha512-IpdytjudNuLv8nhlHs/UrVBhU0e78J0oIS/0AVdTbWxSOkFUVdsHC/NrorO6nXsQNDTT1kzDSOMJubBQviX18Q==" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/playwright": { + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.0.tgz", + "integrity": "sha512-ghGNnIEYZC4E+YtclRn4/p6oYbdPiASELBIYkBXfaTVKreQUYbMUYQDwS12a8F0/HtIjr/CkGjtwABeFPGcS4Q==", + "dev": true, + "dependencies": { + "playwright-core": "1.53.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.0.tgz", + "integrity": "sha512-mGLg8m0pm4+mmtB7M89Xw/GSqoNC+twivl8ITteqvAndachozYe2ZA7srU6uleV1vEdAHYqjq+SV8SNxRRFYBw==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", + "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "dev": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "dev": true, + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "dev": true, + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true + }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/selderee": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz", + "integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==", + "dev": true, + "dependencies": { + "parseley": "^0.12.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "dev": true, + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/seq-queue": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==" + }, + "node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "dev": true, + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/smtp-server": { + "version": "3.13.8", + "resolved": "https://registry.npmjs.org/smtp-server/-/smtp-server-3.13.8.tgz", + "integrity": "sha512-bKYZ/ortxX8Wvi7bCT/daoo1aS1BI1CNoWkonXOLXtWhSccWmBikcMlbpHSzUjmv+vNZQDfOv4b55mhpjPlSsg==", + "dev": true, + "dependencies": { + "base32.js": "0.1.0", + "ipv6-normalize": "1.0.1", + "nodemailer": "7.0.3", + "punycode.js": "2.3.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/smtp-server/node_modules/nodemailer": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.3.tgz", + "integrity": "sha512-Ajq6Sz1x7cIK3pN6KesGTah+1gnwMnx5gKl3piQlQQE/PwyJ4Mbc8is2psWYxK3RJTVeqsDaCv8ZzXLCDHMTZw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/socket.io": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz", + "integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==", + "dev": true, + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.3.2", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz", + "integrity": "sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==", + "dev": true, + "dependencies": { + "debug": "~4.3.4", + "ws": "~8.17.1" + } + }, + "node_modules/socket.io-adapter/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-adapter/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/socket.io-adapter/node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "dev": true, + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-parser/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/socket.io/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sqlstring": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", + "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true + }, + "node_modules/tlds": { + "version": "1.259.0", + "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.259.0.tgz", + "integrity": "sha512-AldGGlDP0PNgwppe2quAvuBl18UcjuNtOnDuUkqhd6ipPqrYYBt3aTxK1QTsBVknk97lS2JcafWMghjGWFtunw==", + "dev": true, + "bin": { + "tlds": "bin.js" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dev": true, + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dev": true, + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "dev": true + }, + "node_modules/undici-types": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", + "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", + "dev": true + }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "dev": true, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dev": true, + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/wildstring": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/wildstring/-/wildstring-1.0.9.tgz", + "integrity": "sha512-XBNxKIMLO6uVHf1Xvo++HGWAZZoiVCHmEMCmZJzJ82vQsuUJCLw13Gzq0mRCATk7a3+ZcgeOKSDioavuYqtlfA==", + "dev": true + }, + "node_modules/ws": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==", + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/playwright/package.json b/playwright/package.json new file mode 100644 index 00000000..bd616816 --- /dev/null +++ b/playwright/package.json @@ -0,0 +1,21 @@ +{ + "name": "scenarios", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": {}, + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "@playwright/test": "^1.53.0", + "dotenv": "^16.5.0", + "dotenv-expand": "^12.0.2", + "maildev": "npm:@timshel_npm/maildev@^3.1.2" + }, + "dependencies": { + "mysql2": "^3.14.1", + "otpauth": "^9.4.0", + "pg": "^8.16.0" + } +} diff --git a/playwright/playwright.config.ts b/playwright/playwright.config.ts new file mode 100644 index 00000000..0f0df9c2 --- /dev/null +++ b/playwright/playwright.config.ts @@ -0,0 +1,143 @@ +import { defineConfig, devices } from '@playwright/test'; +import { exec } from 'node:child_process'; + +const utils = require('./global-utils'); + +utils.loadEnv(); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './.', + /* Run tests in files in parallel */ + fullyParallel: false, + + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + + retries: 0, + workers: 1, + + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + + /* Long global timeout for complex tests + * But short action/nav/expect timeouts to fail on specific step (raise locally if not enough). + */ + timeout: 120 * 1000, + actionTimeout: 10 * 1000, + navigationTimeout: 10 * 1000, + expect: { timeout: 10 * 1000 }, + + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: process.env.DOMAIN, + browserName: 'firefox', + locale: 'en-GB', + timezoneId: 'Europe/London', + + /* Always collect trace (other values add random test failures) See https://playwright.dev/docs/trace-viewer */ + trace: 'on', + viewport: { + width: 1080, + height: 720, + }, + video: "on", + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'mariadb-setup', + testMatch: 'tests/setups/db-setup.ts', + use: { serviceName: "Mariadb" }, + teardown: 'mariadb-teardown', + }, + { + name: 'mysql-setup', + testMatch: 'tests/setups/db-setup.ts', + use: { serviceName: "Mysql" }, + teardown: 'mysql-teardown', + }, + { + name: 'postgres-setup', + testMatch: 'tests/setups/db-setup.ts', + use: { serviceName: "Postgres" }, + teardown: 'postgres-teardown', + }, + { + name: 'sso-setup', + testMatch: 'tests/setups/sso-setup.ts', + teardown: 'sso-teardown', + }, + + { + name: 'mariadb', + testMatch: 'tests/*.spec.ts', + testIgnore: 'tests/sso_*.spec.ts', + dependencies: ['mariadb-setup'], + }, + { + name: 'mysql', + testMatch: 'tests/*.spec.ts', + testIgnore: 'tests/sso_*.spec.ts', + dependencies: ['mysql-setup'], + }, + { + name: 'postgres', + testMatch: 'tests/*.spec.ts', + testIgnore: 'tests/sso_*.spec.ts', + dependencies: ['postgres-setup'], + }, + { + name: 'sqlite', + testMatch: 'tests/*.spec.ts', + testIgnore: 'tests/sso_*.spec.ts', + }, + + { + name: 'sso-mariadb', + testMatch: 'tests/sso_*.spec.ts', + dependencies: ['sso-setup', 'mariadb-setup'], + }, + { + name: 'sso-mysql', + testMatch: 'tests/sso_*.spec.ts', + dependencies: ['sso-setup', 'mysql-setup'], + }, + { + name: 'sso-postgres', + testMatch: 'tests/sso_*.spec.ts', + dependencies: ['sso-setup', 'postgres-setup'], + }, + { + name: 'sso-sqlite', + testMatch: 'tests/sso_*.spec.ts', + dependencies: ['sso-setup'], + }, + + { + name: 'mariadb-teardown', + testMatch: 'tests/setups/db-teardown.ts', + use: { serviceName: "Mariadb" }, + }, + { + name: 'mysql-teardown', + testMatch: 'tests/setups/db-teardown.ts', + use: { serviceName: "Mysql" }, + }, + { + name: 'postgres-teardown', + testMatch: 'tests/setups/db-teardown.ts', + use: { serviceName: "Postgres" }, + }, + { + name: 'sso-teardown', + testMatch: 'tests/setups/sso-teardown.ts', + }, + ], + + globalSetup: require.resolve('./global-setup'), +}); diff --git a/playwright/test.env b/playwright/test.env new file mode 100644 index 00000000..4524fcb6 --- /dev/null +++ b/playwright/test.env @@ -0,0 +1,93 @@ +################################################################## +### Shared Playwright conf test file Vaultwarden and Databases ### +################################################################## + +ENV=test +DC_ENV_FILE=test.env +COMPOSE_IGNORE_ORPHANS=True +DOCKER_BUILDKIT=1 + +##################### +# Playwright Config # +##################### +PW_KEEP_SERVICE_RUNNNING=${PW_KEEP_SERVICE_RUNNNING:-false} +PW_SMTP_FROM=vaultwarden@playwright.test + +##################### +# Maildev Config # +##################### +MAILDEV_HTTP_PORT=1081 +MAILDEV_SMTP_PORT=1026 +MAILDEV_HOST=127.0.0.1 + +################ +# Users Config # +################ +TEST_USER=test +TEST_USER_PASSWORD=Master Password +TEST_USER_MAIL=${TEST_USER}@example.com + +TEST_USER2=test2 +TEST_USER2_PASSWORD=Master Password +TEST_USER2_MAIL=${TEST_USER2}@example.com + +TEST_USER3=test3 +TEST_USER3_PASSWORD=Master Password +TEST_USER3_MAIL=${TEST_USER3}@example.com + +################### +# Keycloak Config # +################### +KEYCLOAK_ADMIN=admin +KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN} +KC_HTTP_HOST=127.0.0.1 +KC_HTTP_PORT=8081 + +# Script parameters (use Keycloak and VaultWarden config too) +TEST_REALM=test +DUMMY_REALM=dummy +DUMMY_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${DUMMY_REALM} + +###################### +# Vaultwarden Config # +###################### +ROCKET_PORT=8003 +DOMAIN=http://127.0.0.1:${ROCKET_PORT} +LOG_LEVEL=info,oidcwarden::sso=debug +LOGIN_RATELIMIT_MAX_BURST=100 + +SMTP_SECURITY=off +SMTP_PORT=${MAILDEV_SMTP_PORT} +SMTP_FROM_NAME=Vaultwarden +SMTP_TIMEOUT=5 + +SSO_CLIENT_ID=warden +SSO_CLIENT_SECRET=warden +SSO_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${TEST_REALM} +SSO_DEBUG_TOKENS=true + +########################### +# Docker MariaDb container# +########################### +MARIADB_PORT=3307 +MARIADB_ROOT_PASSWORD=warden +MARIADB_USER=warden +MARIADB_PASSWORD=warden +MARIADB_DATABASE=warden + +########################### +# Docker Mysql container# +########################### +MYSQL_PORT=3309 +MYSQL_ROOT_PASSWORD=warden +MYSQL_USER=warden +MYSQL_PASSWORD=warden +MYSQL_DATABASE=warden + +############################ +# Docker Postgres container# +############################ +POSTGRES_PORT=5433 +POSTGRES_USER=warden +POSTGRES_PASSWORD=warden +POSTGRES_DB=warden diff --git a/playwright/tests/collection.spec.ts b/playwright/tests/collection.spec.ts new file mode 100644 index 00000000..786a4644 --- /dev/null +++ b/playwright/tests/collection.spec.ts @@ -0,0 +1,37 @@ +import { test, expect, type TestInfo } from '@playwright/test'; + +import * as utils from "../global-utils"; +import { createAccount } from './setups/user'; + +let users = utils.loadEnv(); + +test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { + await utils.startVault(browser, testInfo); +}); + +test.afterAll('Teardown', async ({}) => { + utils.stopVault(); +}); + +test('Create', async ({ page }) => { + await createAccount(test, page, users.user1); + + await test.step('Create Org', async () => { + await page.getByRole('link', { name: 'New organisation' }).click(); + await page.getByLabel('Organisation name (required)').fill('Test'); + await page.getByRole('button', { name: 'Submit' }).click(); + await page.locator('div').filter({ hasText: 'Members' }).nth(2).click(); + + await utils.checkNotification(page, 'Organisation created'); + }); + + await test.step('Create Collection', async () => { + await page.getByRole('link', { name: 'Collections' }).click(); + await page.getByRole('button', { name: 'New' }).click(); + await page.getByRole('menuitem', { name: 'Collection' }).click(); + await page.getByLabel('Name (required)').fill('RandomCollec'); + await page.getByRole('button', { name: 'Save' }).click(); + await utils.checkNotification(page, 'Created collection RandomCollec'); + await expect(page.getByRole('button', { name: 'RandomCollec' })).toBeVisible(); + }); +}); diff --git a/playwright/tests/login.smtp.spec.ts b/playwright/tests/login.smtp.spec.ts new file mode 100644 index 00000000..2f782c14 --- /dev/null +++ b/playwright/tests/login.smtp.spec.ts @@ -0,0 +1,100 @@ +import { test, expect, type TestInfo } from '@playwright/test'; +import { MailDev } from 'maildev'; + +const utils = require('../global-utils'); +import { createAccount, logUser } from './setups/user'; +import { activateEmail, retrieveEmailCode, disableEmail } from './setups/2fa'; + +let users = utils.loadEnv(); + +let mailserver; + +test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { + mailserver = new MailDev({ + port: process.env.MAILDEV_SMTP_PORT, + web: { port: process.env.MAILDEV_HTTP_PORT }, + }) + + await mailserver.listen(); + + await utils.startVault(browser, testInfo, { + SMTP_HOST: process.env.MAILDEV_HOST, + SMTP_FROM: process.env.PW_SMTP_FROM, + }); +}); + +test.afterAll('Teardown', async ({}) => { + utils.stopVault(); + if( mailserver ){ + await mailserver.close(); + } +}); + +test('Account creation', async ({ page }) => { + const mailBuffer = mailserver.buffer(users.user1.email); + + await createAccount(test, page, users.user1, mailBuffer); + + mailBuffer.close(); +}); + +test('Login', async ({ context, page }) => { + const mailBuffer = mailserver.buffer(users.user1.email); + + await logUser(test, page, users.user1, mailBuffer); + + await test.step('verify email', async () => { + await page.getByText('Verify your account\'s email').click(); + await expect(page.getByText('Verify your account\'s email')).toBeVisible(); + await page.getByRole('button', { name: 'Send email' }).click(); + + await utils.checkNotification(page, 'Check your email inbox for a verification link'); + + const verify = await mailBuffer.expect((m) => m.subject === "Verify Your Email"); + expect(verify.from[0]?.address).toBe(process.env.PW_SMTP_FROM); + + const page2 = await context.newPage(); + await page2.setContent(verify.html); + const link = await page2.getByTestId("verify").getAttribute("href"); + await page2.close(); + + await page.goto(link); + await utils.checkNotification(page, 'Account email verified'); + }); + + mailBuffer.close(); +}); + +test('Activate 2fa', async ({ page }) => { + const emails = mailserver.buffer(users.user1.email); + + await logUser(test, page, users.user1); + + await activateEmail(test, page, users.user1, emails); + + emails.close(); +}); + +test('2fa', async ({ page }) => { + const emails = mailserver.buffer(users.user1.email); + + await test.step('login', async () => { + await page.goto('/'); + + await page.getByLabel(/Email address/).fill(users.user1.email); + await page.getByRole('button', { name: 'Continue' }).click(); + await page.getByLabel('Master password').fill(users.user1.password); + await page.getByRole('button', { name: 'Log in with master password' }).click(); + + await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible(); + const code = await retrieveEmailCode(test, page, emails); + await page.getByLabel(/Verification code/).fill(code); + await page.getByRole('button', { name: 'Continue' }).click(); + + await expect(page).toHaveTitle(/Vaults/); + }) + + await disableEmail(test, page, users.user1); + + emails.close(); +}); diff --git a/playwright/tests/login.spec.ts b/playwright/tests/login.spec.ts new file mode 100644 index 00000000..aaac4708 --- /dev/null +++ b/playwright/tests/login.spec.ts @@ -0,0 +1,51 @@ +import { test, expect, type Page, type TestInfo } from '@playwright/test'; +import * as OTPAuth from "otpauth"; + +import * as utils from "../global-utils"; +import { createAccount, logUser } from './setups/user'; +import { activateTOTP, disableTOTP } from './setups/2fa'; + +let users = utils.loadEnv(); +let totp; + +test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { + await utils.startVault(browser, testInfo, {}); +}); + +test.afterAll('Teardown', async ({}) => { + utils.stopVault(); +}); + +test('Account creation', async ({ page }) => { + await createAccount(test, page, users.user1); +}); + +test('Master password login', async ({ page }) => { + await logUser(test, page, users.user1); +}); + +test('Authenticator 2fa', async ({ page }) => { + await logUser(test, page, users.user1); + + let totp = await activateTOTP(test, page, users.user1); + + await utils.logout(test, page, users.user1); + + await test.step('login', async () => { + let timestamp = Date.now(); // Needed to use the next token + timestamp = timestamp + (totp.period - (Math.floor(timestamp / 1000) % totp.period) + 1) * 1000; + + await page.getByLabel(/Email address/).fill(users.user1.email); + await page.getByRole('button', { name: 'Continue' }).click(); + await page.getByLabel('Master password').fill(users.user1.password); + await page.getByRole('button', { name: 'Log in with master password' }).click(); + + await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible(); + await page.getByLabel(/Verification code/).fill(totp.generate({timestamp})); + await page.getByRole('button', { name: 'Continue' }).click(); + + await expect(page).toHaveTitle(/Vaultwarden Web/); + }); + + await disableTOTP(test, page, users.user1); +}); diff --git a/playwright/tests/organization.smtp.spec.ts b/playwright/tests/organization.smtp.spec.ts new file mode 100644 index 00000000..764f9017 --- /dev/null +++ b/playwright/tests/organization.smtp.spec.ts @@ -0,0 +1,115 @@ +import { test, expect, type TestInfo } from '@playwright/test'; +import { MailDev } from 'maildev'; + +import * as utils from '../global-utils'; +import * as orgs from './setups/orgs'; +import { createAccount, logUser } from './setups/user'; + +let users = utils.loadEnv(); + +let mailServer, mail1Buffer, mail2Buffer, mail3Buffer; + +test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { + mailServer = new MailDev({ + port: process.env.MAILDEV_SMTP_PORT, + web: { port: process.env.MAILDEV_HTTP_PORT }, + }) + + await mailServer.listen(); + + await utils.startVault(browser, testInfo, { + SMTP_HOST: process.env.MAILDEV_HOST, + SMTP_FROM: process.env.PW_SMTP_FROM, + }); + + mail1Buffer = mailServer.buffer(users.user1.email); + mail2Buffer = mailServer.buffer(users.user2.email); + mail3Buffer = mailServer.buffer(users.user3.email); +}); + +test.afterAll('Teardown', async ({}, testInfo: TestInfo) => { + utils.stopVault(testInfo); + [mail1Buffer, mail2Buffer, mail3Buffer, mailServer].map((m) => m?.close()); +}); + +test('Create user3', async ({ page }) => { + await createAccount(test, page, users.user3, mail3Buffer); +}); + +test('Invite users', async ({ page }) => { + await createAccount(test, page, users.user1, mail1Buffer); + + await orgs.create(test, page, 'Test'); + await orgs.members(test, page, 'Test'); + await orgs.invite(test, page, 'Test', users.user2.email); + await orgs.invite(test, page, 'Test', users.user3.email, { + navigate: false, + }); +}); + +test('invited with new account', async ({ page }) => { + const invited = await mail2Buffer.expect((mail) => mail.subject === 'Join Test'); + + await test.step('Create account', async () => { + await page.setContent(invited.html); + const link = await page.getByTestId('invite').getAttribute('href'); + await page.goto(link); + await expect(page).toHaveTitle(/Create account | Vaultwarden Web/); + + //await page.getByLabel('Name').fill(users.user2.name); + await page.getByLabel('New master password (required)', { exact: true }).fill(users.user2.password); + await page.getByLabel('Confirm new master password (').fill(users.user2.password); + await page.getByRole('button', { name: 'Create account' }).click(); + await utils.checkNotification(page, 'Your new account has been created'); + + // Redirected to the vault + await expect(page).toHaveTitle('Vaults | Vaultwarden Web'); + await utils.checkNotification(page, 'You have been logged in!'); + await utils.checkNotification(page, 'Invitation accepted'); + }); + + await test.step('Check mails', async () => { + await mail2Buffer.expect((m) => m.subject === 'Welcome'); + await mail2Buffer.expect((m) => m.subject === 'New Device Logged In From Firefox'); + await mail1Buffer.expect((m) => m.subject.includes('Invitation to Test accepted')); + }); +}); + +test('invited with existing account', async ({ page }) => { + const invited = await mail3Buffer.expect((mail) => mail.subject === 'Join Test'); + + await page.setContent(invited.html); + const link = await page.getByTestId('invite').getAttribute('href'); + + await page.goto(link); + + // We should be on login page with email prefilled + await expect(page).toHaveTitle(/Vaultwarden Web/); + await page.getByRole('button', { name: 'Continue' }).click(); + + // Unlock page + await page.getByLabel('Master password').fill(users.user3.password); + await page.getByRole('button', { name: 'Log in with master password' }).click(); + + // We are now in the default vault page + await expect(page).toHaveTitle(/Vaultwarden Web/); + await utils.checkNotification(page, 'Invitation accepted'); + + await mail3Buffer.expect((m) => m.subject === 'New Device Logged In From Firefox'); + await mail1Buffer.expect((m) => m.subject.includes('Invitation to Test accepted')); +}); + +test('Confirm invited user', async ({ page }) => { + await logUser(test, page, users.user1, mail1Buffer); + + await orgs.members(test, page, 'Test'); + await orgs.confirm(test, page, 'Test', users.user2.email); + + await mail2Buffer.expect((m) => m.subject.includes('Invitation to Test confirmed')); +}); + +test('Organization is visible', async ({ page }) => { + await logUser(test, page, users.user2, mail2Buffer); + await page.getByRole('button', { name: 'vault: Test', exact: true }).click(); + await expect(page.getByLabel('Filter: Default collection')).toBeVisible(); +}); diff --git a/playwright/tests/organization.spec.ts b/playwright/tests/organization.spec.ts new file mode 100644 index 00000000..4e644fa7 --- /dev/null +++ b/playwright/tests/organization.spec.ts @@ -0,0 +1,54 @@ +import { test, expect, type TestInfo } from '@playwright/test'; +import { MailDev } from 'maildev'; + +import * as utils from "../global-utils"; +import * as orgs from './setups/orgs'; +import { createAccount, logUser } from './setups/user'; + +let users = utils.loadEnv(); + +test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { + await utils.startVault(browser, testInfo); +}); + +test.afterAll('Teardown', async ({}) => { + utils.stopVault(); +}); + +test('Invite', async ({ page }) => { + await createAccount(test, page, users.user3); + await createAccount(test, page, users.user1); + + await orgs.create(test, page, 'New organisation'); + await orgs.members(test, page, 'New organisation'); + + await test.step('missing user2', async () => { + await orgs.invite(test, page, 'New organisation', users.user2.email); + await expect(page.getByRole('row', { name: users.user2.email })).toHaveText(/Invited/); + }); + + await test.step('existing user3', async () => { + await orgs.invite(test, page, 'New organisation', users.user3.email); + await expect(page.getByRole('row', { name: users.user3.email })).toHaveText(/Needs confirmation/); + await orgs.confirm(test, page, 'New organisation', users.user3.email); + }); + + await test.step('confirm user2', async () => { + await createAccount(test, page, users.user2); + await logUser(test, page, users.user1); + await orgs.members(test, page, 'New organisation'); + await orgs.confirm(test, page, 'New organisation', users.user2.email); + }); + + await test.step('Org visible user2 ', async () => { + await logUser(test, page, users.user2); + await page.getByRole('button', { name: 'vault: New organisation', exact: true }).click(); + await expect(page.getByLabel('Filter: Default collection')).toBeVisible(); + }); + + await test.step('Org visible user3 ', async () => { + await logUser(test, page, users.user3); + await page.getByRole('button', { name: 'vault: New organisation', exact: true }).click(); + await expect(page.getByLabel('Filter: Default collection')).toBeVisible(); + }); +}); diff --git a/playwright/tests/setups/2fa.ts b/playwright/tests/setups/2fa.ts new file mode 100644 index 00000000..1406083e --- /dev/null +++ b/playwright/tests/setups/2fa.ts @@ -0,0 +1,92 @@ +import { expect, type Page, Test } from '@playwright/test'; +import { type MailBuffer } from 'maildev'; +import * as OTPAuth from "otpauth"; + +import * as utils from '../../global-utils'; + +export async function activateTOTP(test: Test, page: Page, user: { name: string, password: string }): OTPAuth.TOTP { + return await test.step('Activate TOTP 2FA', async () => { + await page.getByRole('button', { name: user.name }).click(); + await page.getByRole('menuitem', { name: 'Account settings' }).click(); + await page.getByRole('link', { name: 'Security' }).click(); + await page.getByRole('link', { name: 'Two-step login' }).click(); + await page.locator('bit-item').filter({ hasText: /Authenticator app/ }).getByRole('button').click(); + await page.getByLabel('Master password (required)').fill(user.password); + await page.getByRole('button', { name: 'Continue' }).click(); + + const secret = await page.getByLabel('Key').innerText(); + let totp = new OTPAuth.TOTP({ secret, period: 30 }); + + await page.getByLabel(/Verification code/).fill(totp.generate()); + await page.getByRole('button', { name: 'Turn on' }).click(); + await page.getByRole('heading', { name: 'Turned on', exact: true }); + await page.getByLabel('Close').click(); + + return totp; + }) +} + +export async function disableTOTP(test: Test, page: Page, user: { password: string }) { + await test.step('Disable TOTP 2FA', async () => { + await page.getByRole('button', { name: 'Test' }).click(); + await page.getByRole('menuitem', { name: 'Account settings' }).click(); + await page.getByRole('link', { name: 'Security' }).click(); + await page.getByRole('link', { name: 'Two-step login' }).click(); + await page.locator('bit-item').filter({ hasText: /Authenticator app/ }).getByRole('button').click(); + await page.getByLabel('Master password (required)').click(); + await page.getByLabel('Master password (required)').fill(user.password); + await page.getByRole('button', { name: 'Continue' }).click(); + await page.getByRole('button', { name: 'Turn off' }).click(); + await page.getByRole('button', { name: 'Yes' }).click(); + await utils.checkNotification(page, 'Two-step login provider turned off'); + }); +} + +export async function activateEmail(test: Test, page: Page, user: { name: string, password: string }, mailBuffer: MailBuffer) { + await test.step('Activate Email 2FA', async () => { + await page.getByRole('button', { name: user.name }).click(); + await page.getByRole('menuitem', { name: 'Account settings' }).click(); + await page.getByRole('link', { name: 'Security' }).click(); + await page.getByRole('link', { name: 'Two-step login' }).click(); + await page.locator('bit-item').filter({ hasText: 'Email Email Enter a code sent' }).getByRole('button').click(); + await page.getByLabel('Master password (required)').fill(user.password); + await page.getByRole('button', { name: 'Continue' }).click(); + await page.getByRole('button', { name: 'Send email' }).click(); + }); + + let code = await retrieveEmailCode(test, page, mailBuffer); + + await test.step('input code', async () => { + await page.getByLabel('2. Enter the resulting 6').fill(code); + await page.getByRole('button', { name: 'Turn on' }).click(); + await page.getByRole('heading', { name: 'Turned on', exact: true }); + }); +} + +export async function retrieveEmailCode(test: Test, page: Page, mailBuffer: MailBuffer): string { + return await test.step('retrieve code', async () => { + const codeMail = await mailBuffer.expect((mail) => mail.subject.includes("Login Verification Code")); + const page2 = await page.context().newPage(); + await page2.setContent(codeMail.html); + const code = await page2.getByTestId("2fa").innerText(); + await page2.close(); + return code; + }); +} + +export async function disableEmail(test: Test, page: Page, user: { password: string }) { + await test.step('Disable Email 2FA', async () => { + await page.getByRole('button', { name: 'Test' }).click(); + await page.getByRole('menuitem', { name: 'Account settings' }).click(); + await page.getByRole('link', { name: 'Security' }).click(); + await page.getByRole('link', { name: 'Two-step login' }).click(); + await page.locator('bit-item').filter({ hasText: 'Email' }).getByRole('button').click(); + await page.getByLabel('Master password (required)').click(); + await page.getByLabel('Master password (required)').fill(user.password); + await page.getByRole('button', { name: 'Continue' }).click(); + await page.getByRole('button', { name: 'Turn off' }).click(); + await page.getByRole('button', { name: 'Yes' }).click(); + + await utils.checkNotification(page, 'Two-step login provider turned off'); + }); +} diff --git a/playwright/tests/setups/db-setup.ts b/playwright/tests/setups/db-setup.ts new file mode 100644 index 00000000..eb37fdc1 --- /dev/null +++ b/playwright/tests/setups/db-setup.ts @@ -0,0 +1,7 @@ +import { test } from './db-test'; + +const utils = require('../../global-utils'); + +test('DB start', async ({ serviceName }) => { + utils.startComposeService(serviceName); +}); diff --git a/playwright/tests/setups/db-teardown.ts b/playwright/tests/setups/db-teardown.ts new file mode 100644 index 00000000..5f753a9d --- /dev/null +++ b/playwright/tests/setups/db-teardown.ts @@ -0,0 +1,11 @@ +import { test } from './db-test'; + +const utils = require('../../global-utils'); + +utils.loadEnv(); + +test('DB teardown ?', async ({ serviceName }) => { + if( process.env.PW_KEEP_SERVICE_RUNNNING !== "true" ) { + utils.stopComposeService(serviceName); + } +}); diff --git a/playwright/tests/setups/db-test.ts b/playwright/tests/setups/db-test.ts new file mode 100644 index 00000000..4a72d37c --- /dev/null +++ b/playwright/tests/setups/db-test.ts @@ -0,0 +1,9 @@ +import { test as base } from '@playwright/test'; + +export type TestOptions = { + serviceName: string; +}; + +export const test = base.extend({ + serviceName: ['', { option: true }], +}); diff --git a/playwright/tests/setups/orgs.ts b/playwright/tests/setups/orgs.ts new file mode 100644 index 00000000..ba61b46a --- /dev/null +++ b/playwright/tests/setups/orgs.ts @@ -0,0 +1,75 @@ +import { expect, type Browser,Page } from '@playwright/test'; + +import * as utils from '../../global-utils'; + +export async function create(test, page: Page, name: string) { + await test.step('Create Org', async () => { + await page.locator('a').filter({ hasText: 'Password Manager' }).first().click(); + await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible(); + await page.getByRole('link', { name: 'New organisation' }).click(); + await page.getByLabel('Organisation name (required)').fill(name); + await page.getByRole('button', { name: 'Submit' }).click(); + + await utils.checkNotification(page, 'Organisation created'); + }); +} + +export async function policies(test, page: Page, name: string) { + await test.step(`Navigate to ${name} policies`, async () => { + await page.locator('a').filter({ hasText: 'Admin Console' }).first().click(); + await page.locator('org-switcher').getByLabel(/Toggle collapse/).click(); + await page.locator('org-switcher').getByRole('link', { name: `${name}` }).first().click(); + await expect(page.getByRole('heading', { name: `${name} collections` })).toBeVisible(); + await page.getByRole('button', { name: 'Toggle collapse Settings' }).click(); + await page.getByRole('link', { name: 'Policies' }).click(); + await expect(page.getByRole('heading', { name: 'Policies' })).toBeVisible(); + }); +} + +export async function members(test, page: Page, name: string) { + await test.step(`Navigate to ${name} members`, async () => { + await page.locator('a').filter({ hasText: 'Admin Console' }).first().click(); + await page.locator('org-switcher').getByLabel(/Toggle collapse/).click(); + await page.locator('org-switcher').getByRole('link', { name: `${name}` }).first().click(); + await expect(page.getByRole('heading', { name: `${name} collections` })).toBeVisible(); + await page.locator('div').filter({ hasText: 'Members' }).nth(2).click(); + await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible(); + await expect(page.getByRole('cell', { name: 'All' })).toBeVisible(); + }); +} + +export async function invite(test, page: Page, name: string, email: string) { + await test.step(`Invite ${email}`, async () => { + await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible(); + await page.getByRole('button', { name: 'Invite member' }).click(); + await page.getByLabel('Email (required)').fill(email); + await page.getByRole('tab', { name: 'Collections' }).click(); + await page.getByLabel('Permission').selectOption('edit'); + await page.getByLabel('Select collections').click(); + await page.getByLabel('Options list').getByText('Default collection').click(); + await page.getByRole('button', { name: 'Save' }).click(); + await utils.checkNotification(page, 'User(s) invited'); + }); +} + +export async function confirm(test, page: Page, name: string, user_email: string) { + await test.step(`Confirm ${user_email}`, async () => { + await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible(); + await page.getByRole('row').filter({hasText: user_email}).getByLabel('Options').click(); + await page.getByRole('menuitem', { name: 'Confirm' }).click(); + await expect(page.getByRole('heading', { name: 'Confirm user' })).toBeVisible(); + await page.getByRole('button', { name: 'Confirm' }).click(); + await utils.checkNotification(page, 'confirmed'); + }); +} + +export async function revoke(test, page: Page, name: string, user_email: string) { + await test.step(`Revoke ${user_email}`, async () => { + await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible(); + await page.getByRole('row').filter({hasText: user_email}).getByLabel('Options').click(); + await page.getByRole('menuitem', { name: 'Revoke access' }).click(); + await expect(page.getByRole('heading', { name: 'Revoke access' })).toBeVisible(); + await page.getByRole('button', { name: 'Revoke access' }).click(); + await utils.checkNotification(page, 'Revoked organisation access'); + }); +} diff --git a/playwright/tests/setups/sso-setup.ts b/playwright/tests/setups/sso-setup.ts new file mode 100644 index 00000000..2ac92c0b --- /dev/null +++ b/playwright/tests/setups/sso-setup.ts @@ -0,0 +1,18 @@ +import { test, expect, type TestInfo } from '@playwright/test'; + +const { exec } = require('node:child_process'); +const utils = require('../../global-utils'); + +utils.loadEnv(); + +test.beforeAll('Setup', async () => { + console.log("Starting Keycloak"); + exec(`docker compose --profile keycloak --env-file test.env up`); +}); + +test('Keycloak is up', async ({ page }) => { + await utils.waitFor(process.env.SSO_AUTHORITY, page.context().browser()); + // Dummy authority is created at the end of the setup + await utils.waitFor(process.env.DUMMY_AUTHORITY, page.context().browser()); + console.log(`Keycloak running on: ${process.env.SSO_AUTHORITY}`); +}); diff --git a/playwright/tests/setups/sso-teardown.ts b/playwright/tests/setups/sso-teardown.ts new file mode 100644 index 00000000..2899afff --- /dev/null +++ b/playwright/tests/setups/sso-teardown.ts @@ -0,0 +1,15 @@ +import { test, type FullConfig } from '@playwright/test'; + +const { execSync } = require('node:child_process'); +const utils = require('../../global-utils'); + +utils.loadEnv(); + +test('Keycloak teardown', async () => { + if( process.env.PW_KEEP_SERVICE_RUNNNING === "true" ) { + console.log("Keep Keycloak running"); + } else { + console.log("Keycloak stopping"); + execSync(`docker compose --profile keycloak --env-file test.env stop Keycloak`); + } +}); diff --git a/playwright/tests/setups/sso.ts b/playwright/tests/setups/sso.ts new file mode 100644 index 00000000..65e337f3 --- /dev/null +++ b/playwright/tests/setups/sso.ts @@ -0,0 +1,138 @@ +import { expect, type Page, Test } from '@playwright/test'; +import { type MailBuffer, MailServer } from 'maildev'; +import * as OTPAuth from "otpauth"; + +import * as utils from '../../global-utils'; +import { retrieveEmailCode } from './2fa'; + +/** + * If a MailBuffer is passed it will be used and consume the expected emails + */ +export async function logNewUser( + test: Test, + page: Page, + user: { email: string, name: string, password: string }, + options: { mailBuffer?: MailBuffer, override?: boolean } = {} +) { + await test.step(`Create user ${user.name}`, async () => { + await page.context().clearCookies(); + + await test.step('Landing page', async () => { + await utils.cleanLanding(page); + + if( options.override ) { + await page.getByRole('button', { name: 'Continue' }).click(); + } else { + await page.getByLabel(/Email address/).fill(user.email); + await page.getByRole('button', { name: /Use single sign-on/ }).click(); + } + }); + + await test.step('Keycloak login', async () => { + await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible(); + await page.getByLabel(/Username/).fill(user.name); + await page.getByLabel('Password', { exact: true }).fill(user.password); + await page.getByRole('button', { name: 'Sign In' }).click(); + }); + + await test.step('Create Vault account', async () => { + await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible(); + await page.getByLabel('New master password (required)', { exact: true }).fill(user.password); + await page.getByLabel('Confirm new master password (').fill(user.password); + await page.getByRole('button', { name: 'Create account' }).click(); + }); + + await test.step('Default vault page', async () => { + await expect(page).toHaveTitle(/Vaultwarden Web/); + await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible(); + }); + + await utils.checkNotification(page, 'Account successfully created!'); + await utils.checkNotification(page, 'Invitation accepted'); + + if( options.mailBuffer ){ + let mailBuffer = options.mailBuffer; + await test.step('Check emails', async () => { + await mailBuffer.expect((m) => m.subject === "Welcome"); + await mailBuffer.expect((m) => m.subject.includes("New Device Logged")); + }); + } + }); +} + +/** + * If a MailBuffer is passed it will be used and consume the expected emails + */ +export async function logUser( + test: Test, + page: Page, + user: { email: string, password: string }, + options: { + mailBuffer ?: MailBuffer, + override?: boolean, + totp?: OTPAuth.TOTP, + mail2fa?: boolean, + } = {} +) { + let mailBuffer = options.mailBuffer; + + await test.step(`Log user ${user.email}`, async () => { + await page.context().clearCookies(); + + await test.step('Landing page', async () => { + await utils.cleanLanding(page); + + if( options.override ) { + await page.getByRole('button', { name: 'Continue' }).click(); + } else { + await page.getByLabel(/Email address/).fill(user.email); + await page.getByRole('button', { name: /Use single sign-on/ }).click(); + } + }); + + await test.step('Keycloak login', async () => { + await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible(); + await page.getByLabel(/Username/).fill(user.name); + await page.getByLabel('Password', { exact: true }).fill(user.password); + await page.getByRole('button', { name: 'Sign In' }).click(); + }); + + if( options.totp || options.mail2fa ){ + let code; + + await test.step('2FA check', async () => { + await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible(); + + if( options.totp ) { + const totp = options.totp; + let timestamp = Date.now(); // Needed to use the next token + timestamp = timestamp + (totp.period - (Math.floor(timestamp / 1000) % totp.period) + 1) * 1000; + code = totp.generate({timestamp}); + } else if( options.mail2fa ){ + code = await retrieveEmailCode(test, page, mailBuffer); + } + + await page.getByLabel(/Verification code/).fill(code); + await page.getByRole('button', { name: 'Continue' }).click(); + }); + } + + await test.step('Unlock vault', async () => { + await expect(page).toHaveTitle('Vaultwarden Web'); + await expect(page.getByRole('heading', { name: 'Your vault is locked' })).toBeVisible(); + await page.getByLabel('Master password').fill(user.password); + await page.getByRole('button', { name: 'Unlock' }).click(); + }); + + await test.step('Default vault page', async () => { + await expect(page).toHaveTitle(/Vaultwarden Web/); + await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible(); + }); + + if( mailBuffer ){ + await test.step('Check email', async () => { + await mailBuffer.expect((m) => m.subject.includes("New Device Logged")); + }); + } + }); +} diff --git a/playwright/tests/setups/user.ts b/playwright/tests/setups/user.ts new file mode 100644 index 00000000..45fd86a0 --- /dev/null +++ b/playwright/tests/setups/user.ts @@ -0,0 +1,55 @@ +import { expect, type Browser, Page } from '@playwright/test'; + +import { type MailBuffer } from 'maildev'; + +import * as utils from '../../global-utils'; + +export async function createAccount(test, page: Page, user: { email: string, name: string, password: string }, mailBuffer?: MailBuffer) { + await test.step(`Create user ${user.name}`, async () => { + await utils.cleanLanding(page); + + await page.getByRole('link', { name: 'Create account' }).click(); + + // Back to Vault create account + await expect(page).toHaveTitle(/Create account | Vaultwarden Web/); + await page.getByLabel(/Email address/).fill(user.email); + await page.getByLabel('Name').fill(user.name); + await page.getByRole('button', { name: 'Continue' }).click(); + + // Vault finish Creation + await page.getByLabel('New master password (required)', { exact: true }).fill(user.password); + await page.getByLabel('Confirm new master password (').fill(user.password); + await page.getByRole('button', { name: 'Create account' }).click(); + + await utils.checkNotification(page, 'Your new account has been created') + + // We are now in the default vault page + await expect(page).toHaveTitle('Vaults | Vaultwarden Web'); + await utils.checkNotification(page, 'You have been logged in!'); + + if( mailBuffer ){ + await mailBuffer.expect((m) => m.subject === "Welcome"); + await mailBuffer.expect((m) => m.subject === "New Device Logged In From Firefox"); + } + }); +} + +export async function logUser(test, page: Page, user: { email: string, password: string }, mailBuffer?: MailBuffer) { + await test.step(`Log user ${user.email}`, async () => { + await utils.cleanLanding(page); + + await page.getByLabel(/Email address/).fill(user.email); + await page.getByRole('button', { name: 'Continue' }).click(); + + // Unlock page + await page.getByLabel('Master password').fill(user.password); + await page.getByRole('button', { name: 'Log in with master password' }).click(); + + // We are now in the default vault page + await expect(page).toHaveTitle(/Vaultwarden Web/); + + if( mailBuffer ){ + await mailBuffer.expect((m) => m.subject === "New Device Logged In From Firefox"); + } + }); +} diff --git a/playwright/tests/sso_login.smtp.spec.ts b/playwright/tests/sso_login.smtp.spec.ts new file mode 100644 index 00000000..7a615cd6 --- /dev/null +++ b/playwright/tests/sso_login.smtp.spec.ts @@ -0,0 +1,53 @@ +import { test, expect, type TestInfo } from '@playwright/test'; +import { MailDev } from 'maildev'; + +import { logNewUser, logUser } from './setups/sso'; +import { activateEmail, disableEmail } from './setups/2fa'; +import * as utils from "../global-utils"; + +let users = utils.loadEnv(); + +let mailserver; + +test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { + mailserver = new MailDev({ + port: process.env.MAILDEV_SMTP_PORT, + web: { port: process.env.MAILDEV_HTTP_PORT }, + }) + + await mailserver.listen(); + + await utils.startVault(browser, testInfo, { + SSO_ENABLED: true, + SSO_ONLY: false, + SMTP_HOST: process.env.MAILDEV_HOST, + SMTP_FROM: process.env.PW_SMTP_FROM, + }); +}); + +test.afterAll('Teardown', async ({}) => { + utils.stopVault(); + if( mailserver ){ + await mailserver.close(); + } +}); + +test('Create and activate 2FA', async ({ page }) => { + const mailBuffer = mailserver.buffer(users.user1.email); + + await logNewUser(test, page, users.user1, {mailBuffer: mailBuffer}); + + await activateEmail(test, page, users.user1, mailBuffer); + + mailBuffer.close(); +}); + +test('Log and disable', async ({ page }) => { + const mailBuffer = mailserver.buffer(users.user1.email); + + await logUser(test, page, users.user1, {mailBuffer: mailBuffer, mail2fa: true}); + + await disableEmail(test, page, users.user1); + + mailBuffer.close(); +}); diff --git a/playwright/tests/sso_login.spec.ts b/playwright/tests/sso_login.spec.ts new file mode 100644 index 00000000..b4817bed --- /dev/null +++ b/playwright/tests/sso_login.spec.ts @@ -0,0 +1,94 @@ +import { test, expect, type TestInfo } from '@playwright/test'; + +import { logNewUser, logUser } from './setups/sso'; +import { activateTOTP, disableTOTP } from './setups/2fa'; +import * as utils from "../global-utils"; + +let users = utils.loadEnv(); + +test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { + await utils.startVault(browser, testInfo, { + SSO_ENABLED: true, + SSO_ONLY: false + }); +}); + +test.afterAll('Teardown', async ({}) => { + utils.stopVault(); +}); + +test('Account creation using SSO', async ({ page }) => { + // Landing page + await logNewUser(test, page, users.user1); +}); + +test('SSO login', async ({ page }) => { + await logUser(test, page, users.user1); +}); + +test('Non SSO login', async ({ page }) => { + // Landing page + await page.goto('/'); + await page.getByLabel(/Email address/).fill(users.user1.email); + await page.getByRole('button', { name: 'Continue' }).click(); + + // Unlock page + await page.getByLabel('Master password').fill(users.user1.password); + await page.getByRole('button', { name: 'Log in with master password' }).click(); + + // We are now in the default vault page + await expect(page).toHaveTitle(/Vaultwarden Web/); +}); + +test('SSO login with TOTP 2fa', async ({ page }) => { + await logUser(test, page, users.user1); + + let totp = await activateTOTP(test, page, users.user1); + + await logUser(test, page, users.user1, { totp }); + + await disableTOTP(test, page, users.user1); +}); + +test('Non SSO login impossible', async ({ page, browser }, testInfo: TestInfo) => { + await utils.restartVault(page, testInfo, { + SSO_ENABLED: true, + SSO_ONLY: true + }, false); + + // Landing page + await page.goto('/'); + await page.getByLabel(/Email address/).fill(users.user1.email); + + // Check that SSO login is available + await expect(page.getByRole('button', { name: /Use single sign-on/ })).toHaveCount(1); + + await page.getByLabel(/Email address/).fill(users.user1.email); + await page.getByRole('button', { name: 'Continue' }).click(); + + // Unlock page + await page.getByLabel('Master password').fill(users.user1.password); + await page.getByRole('button', { name: 'Log in with master password' }).click(); + + // An error should appear + await page.getByLabel('SSO sign-in is required') +}); + + +test('No SSO login', async ({ page }, testInfo: TestInfo) => { + await utils.restartVault(page, testInfo, { + SSO_ENABLED: false + }, false); + + // Landing page + await page.goto('/'); + await page.getByLabel(/Email address/).fill(users.user1.email); + + // No SSO button (rely on a correct selector checked in previous test) + await page.getByLabel('Master password'); + await expect(page.getByRole('button', { name: /Use single sign-on/ })).toHaveCount(0); + + // Can continue to Master password + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByRole('button', { name: /Log in with master password/ })).toHaveCount(1); +}); diff --git a/playwright/tests/sso_organization.smtp.spec.ts b/playwright/tests/sso_organization.smtp.spec.ts new file mode 100644 index 00000000..45ef5ada --- /dev/null +++ b/playwright/tests/sso_organization.smtp.spec.ts @@ -0,0 +1,121 @@ +import { test, expect, type TestInfo } from '@playwright/test'; +import { MailDev } from 'maildev'; + +import * as utils from "../global-utils"; +import * as orgs from './setups/orgs'; +import { logNewUser, logUser } from './setups/sso'; + +let users = utils.loadEnv(); + +let mailServer, mail1Buffer, mail2Buffer, mail3Buffer; + +test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { + mailServer = new MailDev({ + port: process.env.MAILDEV_SMTP_PORT, + web: { port: process.env.MAILDEV_HTTP_PORT }, + }) + + await mailServer.listen(); + + await utils.startVault(browser, testInfo, { + SMTP_HOST: process.env.MAILDEV_HOST, + SMTP_FROM: process.env.PW_SMTP_FROM, + SSO_ENABLED: true, + SSO_ONLY: true, + }); + + mail1Buffer = mailServer.buffer(users.user1.email); + mail2Buffer = mailServer.buffer(users.user2.email); + mail3Buffer = mailServer.buffer(users.user3.email); +}); + +test.afterAll('Teardown', async ({}) => { + utils.stopVault(); + [mail1Buffer, mail2Buffer, mail3Buffer, mailServer].map((m) => m?.close()); +}); + +test('Create user3', async ({ page }) => { + await logNewUser(test, page, users.user3, { mailBuffer: mail3Buffer }); +}); + +test('Invite users', async ({ page }) => { + await logNewUser(test, page, users.user1, { mailBuffer: mail1Buffer }); + + await orgs.create(test, page, '/Test'); + await orgs.members(test, page, '/Test'); + await orgs.invite(test, page, '/Test', users.user2.email); + await orgs.invite(test, page, '/Test', users.user3.email); +}); + +test('invited with new account', async ({ page }) => { + const link = await test.step('Extract email link', async () => { + const invited = await mail2Buffer.expect((m) => m.subject === "Join /Test"); + await page.setContent(invited.html); + return await page.getByTestId("invite").getAttribute("href"); + }); + + await test.step('Redirect to Keycloak', async () => { + await page.goto(link); + }); + + await test.step('Keycloak login', async () => { + await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible(); + await page.getByLabel(/Username/).fill(users.user2.name); + await page.getByLabel('Password', { exact: true }).fill(users.user2.password); + await page.getByRole('button', { name: 'Sign In' }).click(); + }); + + await test.step('Create Vault account', async () => { + await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible(); + await page.getByLabel('New master password (required)', { exact: true }).fill(users.user2.password); + await page.getByLabel('Confirm new master password (').fill(users.user2.password); + await page.getByRole('button', { name: 'Create account' }).click(); + }); + + await test.step('Default vault page', async () => { + await expect(page).toHaveTitle(/Vaultwarden Web/); + + await utils.checkNotification(page, 'Account successfully created!'); + await utils.checkNotification(page, 'Invitation accepted'); + }); + + await test.step('Check mails', async () => { + await mail2Buffer.expect((m) => m.subject.includes("New Device Logged")); + await mail1Buffer.expect((m) => m.subject === "Invitation to /Test accepted"); + }); +}); + +test('invited with existing account', async ({ page }) => { + const link = await test.step('Extract email link', async () => { + const invited = await mail3Buffer.expect((m) => m.subject === "Join /Test"); + await page.setContent(invited.html); + return await page.getByTestId("invite").getAttribute("href"); + }); + + await test.step('Redirect to Keycloak', async () => { + await page.goto(link); + }); + + await test.step('Keycloak login', async () => { + await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible(); + await page.getByLabel(/Username/).fill(users.user3.name); + await page.getByLabel('Password', { exact: true }).fill(users.user3.password); + await page.getByRole('button', { name: 'Sign In' }).click(); + }); + + await test.step('Unlock vault', async () => { + await expect(page).toHaveTitle('Vaultwarden Web'); + await page.getByLabel('Master password').fill(users.user3.password); + await page.getByRole('button', { name: 'Unlock' }).click(); + }); + + await test.step('Default vault page', async () => { + await expect(page).toHaveTitle(/Vaultwarden Web/); + await utils.checkNotification(page, 'Invitation accepted'); + }); + + await test.step('Check mails', async () => { + await mail3Buffer.expect((m) => m.subject.includes("New Device Logged")); + await mail1Buffer.expect((m) => m.subject === "Invitation to /Test accepted"); + }); +}); diff --git a/playwright/tests/sso_organization.spec.ts b/playwright/tests/sso_organization.spec.ts new file mode 100644 index 00000000..c215ce3b --- /dev/null +++ b/playwright/tests/sso_organization.spec.ts @@ -0,0 +1,76 @@ +import { test, expect, type TestInfo } from '@playwright/test'; +import { MailDev } from 'maildev'; + +import * as utils from "../global-utils"; +import * as orgs from './setups/orgs'; +import { logNewUser, logUser } from './setups/sso'; + +let users = utils.loadEnv(); + +test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { + await utils.startVault(browser, testInfo, { + SSO_ENABLED: true, + SSO_ONLY: true, + }); +}); + +test.afterAll('Teardown', async ({}) => { + utils.stopVault(); +}); + +test('Create user3', async ({ page }) => { + await logNewUser(test, page, users.user3); +}); + +test('Invite users', async ({ page }) => { + await logNewUser(test, page, users.user1); + + await orgs.create(test, page, '/Test'); + await orgs.members(test, page, '/Test'); + await orgs.invite(test, page, '/Test', users.user2.email); + await orgs.invite(test, page, '/Test', users.user3.email); + await orgs.confirm(test, page, '/Test', users.user3.email); +}); + +test('Create invited account', async ({ page }) => { + await logNewUser(test, page, users.user2); +}); + +test('Confirm invited user', async ({ page }) => { + await logUser(test, page, users.user1); + await orgs.members(test, page, '/Test'); + await expect(page.getByRole('row', { name: users.user2.name })).toHaveText(/Needs confirmation/); + await orgs.confirm(test, page, '/Test', users.user2.email); +}); + +test('Organization is visible', async ({ page }) => { + await logUser(test, page, users.user2); + await page.getByLabel('vault: /Test').click(); + await expect(page.getByLabel('Filter: Default collection')).toBeVisible(); +}); + +test('Enforce password policy', async ({ page }) => { + await logUser(test, page, users.user1); + await orgs.policies(test, page, '/Test'); + + await test.step(`Set master password policy`, async () => { + await page.getByRole('button', { name: 'Master password requirements' }).click(); + await page.getByRole('checkbox', { name: 'Turn on' }).check(); + await page.getByRole('checkbox', { name: 'Require existing members to' }).check(); + await page.getByRole('spinbutton', { name: 'Minimum length' }).fill('42'); + await page.getByRole('button', { name: 'Save' }).click(); + await utils.checkNotification(page, 'Edited policy Master password requirements.'); + }); + + await utils.logout(test, page, users.user1); + + await test.step(`Unlock trigger policy`, async () => { + await page.getByRole('textbox', { name: 'Email address (required)' }).fill(users.user1.email); + await page.getByRole('button', { name: 'Use single sign-on' }).click(); + + await page.getByRole('textbox', { name: 'Master password (required)' }).fill(users.user1.password); + await page.getByRole('button', { name: 'Unlock' }).click(); + + await expect(page.getByRole('heading', { name: 'Update master password' })).toBeVisible(); + }); +}); diff --git a/src/api/admin.rs b/src/api/admin.rs index b2601cab..166425cc 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -46,6 +46,7 @@ pub fn routes() -> Vec { invite_user, logout, delete_user, + delete_sso_user, deauth_user, disable_user, enable_user, @@ -239,6 +240,7 @@ struct AdminTemplateData { page_data: Option, logged_in: bool, urlpath: String, + sso_enabled: bool, } impl AdminTemplateData { @@ -248,6 +250,7 @@ impl AdminTemplateData { page_data: Some(page_data), logged_in: true, urlpath: CONFIG.domain_path(), + sso_enabled: CONFIG.sso_enabled(), } } @@ -296,7 +299,7 @@ async fn invite_user(data: Json, _token: AdminToken, mut conn: DbCon err_code!("User already exists", Status::Conflict.code) } - let mut user = User::new(data.email); + let mut user = User::new(data.email, None); async fn _generate_invite(user: &User, conn: &mut DbConn) -> EmptyResult { if CONFIG.mail_enabled() { @@ -336,7 +339,7 @@ fn logout(cookies: &CookieJar<'_>) -> Redirect { async fn get_users_json(_token: AdminToken, mut conn: DbConn) -> Json { let users = User::get_all(&mut conn).await; let mut users_json = Vec::with_capacity(users.len()); - for u in users { + for (u, _) in users { let mut usr = u.to_json(&mut conn).await; usr["userEnabled"] = json!(u.enabled); usr["createdAt"] = json!(format_naive_datetime_local(&u.created_at, DT_FMT)); @@ -354,7 +357,7 @@ async fn get_users_json(_token: AdminToken, mut conn: DbConn) -> Json { async fn users_overview(_token: AdminToken, mut conn: DbConn) -> ApiResult> { let users = User::get_all(&mut conn).await; let mut users_json = Vec::with_capacity(users.len()); - for u in users { + for (u, sso_u) in users { let mut usr = u.to_json(&mut conn).await; usr["cipher_count"] = json!(Cipher::count_owned_by_user(&u.uuid, &mut conn).await); usr["attachment_count"] = json!(Attachment::count_by_user(&u.uuid, &mut conn).await); @@ -365,6 +368,9 @@ async fn users_overview(_token: AdminToken, mut conn: DbConn) -> ApiResult json!(format_naive_datetime_local(&dt, DT_FMT)), None => json!("Never"), }; + + usr["sso_identifier"] = json!(sso_u.map(|u| u.identifier.to_string()).unwrap_or(String::new())); + users_json.push(usr); } @@ -417,6 +423,27 @@ async fn delete_user(user_id: UserId, token: AdminToken, mut conn: DbConn) -> Em res } +#[delete("/users//sso", format = "application/json")] +async fn delete_sso_user(user_id: UserId, token: AdminToken, mut conn: DbConn) -> EmptyResult { + let memberships = Membership::find_any_state_by_user(&user_id, &mut conn).await; + let res = SsoUser::delete(&user_id, &mut conn).await; + + for membership in memberships { + log_event( + EventType::OrganizationUserUnlinkedSso as i32, + &membership.uuid, + &membership.org_uuid, + &ACTING_ADMIN_USER.into(), + 14, // Use UnknownBrowser type + &token.ip.ip, + &mut conn, + ) + .await; + } + + res +} + #[post("/users//deauth", format = "application/json")] async fn deauth_user(user_id: UserId, _token: AdminToken, mut conn: DbConn, nt: Notify<'_>) -> EmptyResult { let mut user = get_user_or_404(&user_id, &mut conn).await?; diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index 7ad625a4..4aea371e 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -7,9 +7,9 @@ use serde_json::Value; use crate::{ api::{ - core::{log_user_event, two_factor::email}, - master_password_policy, register_push_device, unregister_push_device, AnonymousNotify, EmptyResult, JsonResult, - Notify, PasswordOrOtpData, UpdateType, + core::{accept_org_invite, log_user_event, two_factor::email}, + master_password_policy, register_push_device, unregister_push_device, AnonymousNotify, ApiResult, EmptyResult, + JsonResult, Notify, PasswordOrOtpData, UpdateType, }, auth::{decode_delete, decode_invite, decode_verify_email, ClientHeaders, Headers}, crypto, @@ -34,6 +34,7 @@ pub fn routes() -> Vec { get_public_keys, post_keys, post_password, + post_set_password, post_kdf, post_rotatekey, post_sstamp, @@ -97,6 +98,20 @@ pub struct RegisterData { org_invite_token: Option, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SetPasswordData { + kdf: Option, + kdf_iterations: Option, + kdf_memory: Option, + kdf_parallelism: Option, + key: String, + keys: Option, + master_password_hash: String, + master_password_hint: Option, + org_identifier: Option, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct KeysData { @@ -237,10 +252,7 @@ pub async fn _register(data: Json, email_verification: bool, mut c err!("Registration email does not match invite email") } } else if Invitation::take(&email, &mut conn).await { - for membership in Membership::find_invited_by_user(&user.uuid, &mut conn).await.iter_mut() { - membership.status = MembershipStatus::Accepted as i32; - membership.save(&mut conn).await?; - } + Membership::accept_user_invitations(&user.uuid, &mut conn).await?; user } else if CONFIG.is_signup_allowed(&email) || (CONFIG.emergency_access_allowed() @@ -259,7 +271,7 @@ pub async fn _register(data: Json, email_verification: bool, mut c || CONFIG.is_signup_allowed(&email) || pending_emergency_access.is_some() { - User::new(email.clone()) + User::new(email.clone(), None) } else { err!("Registration not allowed or user already exists") } @@ -327,6 +339,77 @@ pub async fn _register(data: Json, email_verification: bool, mut c }))) } +#[post("/accounts/set-password", data = "")] +async fn post_set_password(data: Json, headers: Headers, mut conn: DbConn) -> JsonResult { + let data: SetPasswordData = data.into_inner(); + let mut user = headers.user; + + if user.private_key.is_some() { + err!("Account already intialized cannot set password") + } + + // Check against the password hint setting here so if it fails, the user + // can retry without losing their invitation below. + let password_hint = clean_password_hint(&data.master_password_hint); + enforce_password_hint_setting(&password_hint)?; + + if let Some(client_kdf_iter) = data.kdf_iterations { + user.client_kdf_iter = client_kdf_iter; + } + + if let Some(client_kdf_type) = data.kdf { + user.client_kdf_type = client_kdf_type; + } + + user.client_kdf_memory = data.kdf_memory; + user.client_kdf_parallelism = data.kdf_parallelism; + + user.set_password( + &data.master_password_hash, + Some(data.key), + false, + Some(vec![String::from("revision_date")]), // We need to allow revision-date to use the old security_timestamp + ); + user.password_hint = password_hint; + + if let Some(keys) = data.keys { + user.private_key = Some(keys.encrypted_private_key); + user.public_key = Some(keys.public_key); + } + + if let Some(identifier) = data.org_identifier { + if identifier != crate::sso::FAKE_IDENTIFIER { + let org = match Organization::find_by_name(&identifier, &mut conn).await { + None => err!("Failed to retrieve the associated organization"), + Some(org) => org, + }; + + let membership = match Membership::find_by_user_and_org(&user.uuid, &org.uuid, &mut conn).await { + None => err!("Failed to retrieve the invitation"), + Some(org) => org, + }; + + accept_org_invite(&user, membership, None, &mut conn).await?; + } + } + + if CONFIG.mail_enabled() { + mail::send_welcome(&user.email.to_lowercase()).await?; + } else { + Membership::accept_user_invitations(&user.uuid, &mut conn).await?; + } + + log_user_event(EventType::UserChangedPassword as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &mut conn) + .await; + + user.save(&mut conn).await?; + + Ok(Json(json!({ + "Object": "set-password", + "CaptchaBypassToken": "", + }))) +} + #[get("/accounts/profile")] async fn profile(headers: Headers, mut conn: DbConn) -> Json { Json(headers.user.to_json(&mut conn).await) @@ -1067,15 +1150,30 @@ struct SecretVerificationRequest { master_password_hash: String, } +// Change the KDF Iterations if necessary +pub async fn kdf_upgrade(user: &mut User, pwd_hash: &str, conn: &mut DbConn) -> ApiResult<()> { + if user.password_iterations != CONFIG.password_iterations() { + user.password_iterations = CONFIG.password_iterations(); + user.set_password(pwd_hash, None, false, None); + + if let Err(e) = user.save(conn).await { + error!("Error updating user: {e:#?}"); + } + } + Ok(()) +} + #[post("/accounts/verify-password", data = "")] -async fn verify_password(data: Json, headers: Headers, conn: DbConn) -> JsonResult { +async fn verify_password(data: Json, headers: Headers, mut conn: DbConn) -> JsonResult { let data: SecretVerificationRequest = data.into_inner(); - let user = headers.user; + let mut user = headers.user; if !user.check_valid_password(&data.master_password_hash) { err!("Invalid password") } + kdf_upgrade(&mut user, &data.master_password_hash, &mut conn).await?; + Ok(Json(master_password_policy(&user, &conn).await)) } diff --git a/src/api/core/emergency_access.rs b/src/api/core/emergency_access.rs index 2c9844d6..b6b77df1 100644 --- a/src/api/core/emergency_access.rs +++ b/src/api/core/emergency_access.rs @@ -239,7 +239,7 @@ async fn send_invite(data: Json, headers: Headers, mu invitation.save(&mut conn).await?; } - let mut user = User::new(email.clone()); + let mut user = User::new(email.clone(), None); user.save(&mut conn).await?; (user, true) } diff --git a/src/api/core/mod.rs b/src/api/core/mod.rs index c30b4e8d..43a29ed4 100644 --- a/src/api/core/mod.rs +++ b/src/api/core/mod.rs @@ -50,11 +50,12 @@ pub fn events_routes() -> Vec { use rocket::{serde::json::Json, serde::json::Value, Catcher, Route}; use crate::{ - api::{JsonResult, Notify, UpdateType}, + api::{EmptyResult, JsonResult, Notify, UpdateType}, auth::Headers, - db::DbConn, + db::{models::*, DbConn}, error::Error, http_client::make_http_request, + mail, util::parse_experimental_client_feature_flags, }; @@ -225,7 +226,7 @@ fn config() -> Json { "url": "https://github.com/dani-garcia/vaultwarden" }, "settings": { - "disableUserRegistration": !crate::CONFIG.signups_allowed() && crate::CONFIG.signups_domains_whitelist().is_empty(), + "disableUserRegistration": crate::CONFIG.is_signup_disabled(), }, "environment": { "vault": domain, @@ -259,3 +260,49 @@ fn api_not_found() -> Json { } })) } + +async fn accept_org_invite( + user: &User, + mut member: Membership, + reset_password_key: Option, + conn: &mut DbConn, +) -> EmptyResult { + if member.status != MembershipStatus::Invited as i32 { + err!("User already accepted the invitation"); + } + + // This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type + // It returns different error messages per function. + if member.atype < MembershipType::Admin { + match OrgPolicy::is_user_allowed(&member.user_uuid, &member.org_uuid, false, conn).await { + Ok(_) => {} + Err(OrgPolicyErr::TwoFactorMissing) => { + if crate::CONFIG.email_2fa_auto_fallback() { + two_factor::email::activate_email_2fa(user, conn).await?; + } else { + err!("You cannot join this organization until you enable two-step login on your user account"); + } + } + Err(OrgPolicyErr::SingleOrgEnforced) => { + err!("You cannot join this organization because you are a member of an organization which forbids it"); + } + } + } + + member.status = MembershipStatus::Accepted as i32; + member.reset_password_key = reset_password_key; + + member.save(conn).await?; + + if crate::CONFIG.mail_enabled() { + let org = match Organization::find_by_uuid(&member.org_uuid, conn).await { + Some(org) => org, + None => err!("Organization not found."), + }; + // User was invited to an organization, so they must be confirmed manually after acceptance + mail::send_invite_accepted(&user.email, &member.invited_by_email.unwrap_or(org.billing_email), &org.name) + .await?; + } + + Ok(()) +} diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 737484a1..e5dbdccd 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -7,13 +7,13 @@ use std::collections::{HashMap, HashSet}; use crate::api::admin::FAKE_ADMIN_UUID; use crate::{ api::{ - core::{log_event, two_factor, CipherSyncData, CipherSyncType}, + core::{accept_org_invite, log_event, two_factor, CipherSyncData, CipherSyncType}, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, }, auth::{decode_invite, AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders}, db::{models::*, DbConn}, mail, - util::{convert_json_key_lcase_first, NumberOrString}, + util::{convert_json_key_lcase_first, get_uuid, NumberOrString}, CONFIG, }; @@ -43,6 +43,8 @@ pub fn routes() -> Vec { bulk_delete_organization_collections, post_bulk_collections, get_org_details, + get_org_domain_sso_details, + get_org_domain_sso_verified, get_members, send_invite, reinvite_member, @@ -60,6 +62,7 @@ pub fn routes() -> Vec { post_org_import, list_policies, list_policies_token, + get_master_password_policy, get_policy, put_policy, get_organization_tax, @@ -103,6 +106,7 @@ pub fn routes() -> Vec { api_key, rotate_api_key, get_billing_metadata, + get_auto_enroll_status, ] } @@ -192,7 +196,7 @@ async fn create_organization(headers: Headers, data: Json, mut conn: Db }; let org = Organization::new(data.name, data.billing_email, private_key, public_key); - let mut member = Membership::new(headers.user.uuid, org.uuid.clone()); + let mut member = Membership::new(headers.user.uuid, org.uuid.clone(), None); let collection = Collection::new(org.uuid.clone(), data.collection_name, None); member.akey = data.key; @@ -335,6 +339,34 @@ async fn get_user_collections(headers: Headers, mut conn: DbConn) -> Json })) } +// Called during the SSO enrollment +// The `identifier` should be the value returned by `get_org_domain_sso_details` +// The returned `Id` will then be passed to `get_master_password_policy` which will mainly ignore it +#[get("/organizations//auto-enroll-status")] +async fn get_auto_enroll_status(identifier: &str, headers: Headers, mut conn: DbConn) -> JsonResult { + let org = if identifier == crate::sso::FAKE_IDENTIFIER { + match Membership::find_main_user_org(&headers.user.uuid, &mut conn).await { + Some(member) => Organization::find_by_uuid(&member.org_uuid, &mut conn).await, + None => None, + } + } else { + Organization::find_by_name(identifier, &mut conn).await + }; + + let (id, identifier, rp_auto_enroll) = match org { + None => (get_uuid(), identifier.to_string(), false), + Some(org) => { + (org.uuid.to_string(), org.name, OrgPolicy::org_is_reset_password_auto_enroll(&org.uuid, &mut conn).await) + } + }; + + Ok(Json(json!({ + "Id": id, + "Identifier": identifier, + "ResetPasswordEnabled": rp_auto_enroll, + }))) +} + #[get("/organizations//collections")] async fn get_org_collections(org_id: OrganizationId, headers: ManagerHeadersLoose, mut conn: DbConn) -> JsonResult { if org_id != headers.membership.org_uuid { @@ -939,6 +971,59 @@ async fn _get_org_details( Ok(json!(ciphers_json)) } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct OrgDomainDetails { + email: String, +} + +// Returning a Domain/Organization here allow to prefill it and prevent prompting the user +// So we either return an Org name associated to the user or a dummy value. +// The `verifiedDate` is required but the value ATM is ignored. +// DEPRECATED: still present in `v2025.6.0` but appears unused. +#[post("/organizations/domain/sso/details", data = "")] +async fn get_org_domain_sso_details(data: Json, mut conn: DbConn) -> JsonResult { + let data: OrgDomainDetails = data.into_inner(); + + let identifier = match Organization::find_main_org_user_email(&data.email, &mut conn).await { + Some(org) => org.name, + None => crate::sso::FAKE_IDENTIFIER.to_string(), + }; + + Ok(Json(json!({ + "organizationIdentifier": identifier, + "ssoAvailable": CONFIG.sso_enabled(), + "verifiedDate": crate::util::format_date(&chrono::Utc::now().naive_utc()), + }))) +} + +// Returning a Domain/Organization here allow to prefill it and prevent prompting the user +// So we either return an Org name associated to the user or a dummy value. +// In use since `v2025.6.0`, appears to use only the first `organizationIdentifier` +#[post("/organizations/domain/sso/verified", data = "")] +async fn get_org_domain_sso_verified(data: Json, mut conn: DbConn) -> JsonResult { + let data: OrgDomainDetails = data.into_inner(); + + let identifiers = match Organization::find_org_user_email(&data.email, &mut conn) + .await + .into_iter() + .map(|o| o.name) + .collect::>() + { + v if !v.is_empty() => v, + _ => vec![crate::sso::FAKE_IDENTIFIER.to_string()], + }; + + Ok(Json(json!({ + "object": "list", + "data": identifiers.into_iter().map(|identifier| json!({ + "organizationName": identifier, // appear unused + "organizationIdentifier": identifier, + "domainName": CONFIG.domain(), // appear unused + })).collect::>() + }))) +} + #[derive(FromForm)] struct GetOrgUserData { #[field(name = "includeCollections")] @@ -1072,7 +1157,7 @@ async fn send_invite( Invitation::new(email).save(&mut conn).await?; } - let mut new_user = User::new(email.clone()); + let mut new_user = User::new(email.clone(), None); new_user.save(&mut conn).await?; user_created = true; new_user @@ -1090,7 +1175,7 @@ async fn send_invite( } }; - let mut new_member = Membership::new(user.uuid.clone(), org_id.clone()); + let mut new_member = Membership::new(user.uuid.clone(), org_id.clone(), Some(headers.user.email.clone())); new_member.access_all = access_all; new_member.atype = new_type; new_member.status = member_status; @@ -1276,71 +1361,39 @@ async fn accept_invite( err!("Invitation was issued to a different account", "Claim does not match user_id") } + // If a claim org_id does not match the one in from the URI, something is wrong. + if !claims.org_id.eq(&org_id) { + err!("Error accepting the invitation", "Claim does not match the org_id") + } + // If a claim does not have a member_id or it does not match the one in from the URI, something is wrong. if !claims.member_id.eq(&member_id) { err!("Error accepting the invitation", "Claim does not match the member_id") } - let member = &claims.member_id; - let org = &claims.org_id; - + let member_id = &claims.member_id; Invitation::take(&claims.email, &mut conn).await; // skip invitation logic when we were invited via the /admin panel - if **member != FAKE_ADMIN_UUID { - let Some(mut member) = Membership::find_by_uuid_and_org(member, org, &mut conn).await else { + if **member_id != FAKE_ADMIN_UUID { + let Some(mut member) = Membership::find_by_uuid_and_org(member_id, &claims.org_id, &mut conn).await else { err!("Error accepting the invitation") }; - if member.status != MembershipStatus::Invited as i32 { - err!("User already accepted the invitation") - } - - let master_password_required = OrgPolicy::org_is_reset_password_auto_enroll(org, &mut conn).await; - if data.reset_password_key.is_none() && master_password_required { - err!("Reset password key is required, but not provided."); - } - - // This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type - // It returns different error messages per function. - if member.atype < MembershipType::Admin { - match OrgPolicy::is_user_allowed(&member.user_uuid, &org_id, false, &mut conn).await { - Ok(_) => {} - Err(OrgPolicyErr::TwoFactorMissing) => { - if CONFIG.email_2fa_auto_fallback() { - two_factor::email::activate_email_2fa(&headers.user, &mut conn).await?; - } else { - err!("You cannot join this organization until you enable two-step login on your user account"); - } - } - Err(OrgPolicyErr::SingleOrgEnforced) => { - err!("You cannot join this organization because you are a member of an organization which forbids it"); - } - } - } - - member.status = MembershipStatus::Accepted as i32; - - if master_password_required { - member.reset_password_key = data.reset_password_key; - } + let reset_password_key = match OrgPolicy::org_is_reset_password_auto_enroll(&member.org_uuid, &mut conn).await { + true if data.reset_password_key.is_none() => err!("Reset password key is required, but not provided."), + true => data.reset_password_key, + false => None, + }; - member.save(&mut conn).await?; - } + // In case the user was invited before the mail was saved in db. + member.invited_by_email = member.invited_by_email.or(claims.invited_by_email); - if CONFIG.mail_enabled() { - if let Some(invited_by_email) = &claims.invited_by_email { - let org_name = match Organization::find_by_uuid(&claims.org_id, &mut conn).await { - Some(org) => org.name, - None => err!("Organization not found."), - }; - // User was invited to an organization, so they must be confirmed manually after acceptance - mail::send_invite_accepted(&claims.email, invited_by_email, &org_name).await?; - } else { - // User was invited from /admin, so they are automatically confirmed - let org_name = CONFIG.invitation_org_name(); - mail::send_invite_confirmed(&claims.email, &org_name).await?; - } + accept_org_invite(&headers.user, member, reset_password_key, &mut conn).await?; + } else if CONFIG.mail_enabled() { + // User was invited from /admin, so they are automatically confirmed + let org_name = CONFIG.invitation_org_name(); + mail::send_invite_confirmed(&claims.email, &org_name).await?; } Ok(()) @@ -2034,18 +2087,36 @@ async fn list_policies_token(org_id: OrganizationId, token: &str, mut conn: DbCo }))) } -#[get("/organizations//policies/")] +// Called during the SSO enrollment. +// Return the org policy if it exists, otherwise use the default one. +#[get("/organizations//policies/master-password", rank = 1)] +async fn get_master_password_policy(org_id: OrganizationId, _headers: Headers, mut conn: DbConn) -> JsonResult { + let policy = + OrgPolicy::find_by_org_and_type(&org_id, OrgPolicyType::MasterPassword, &mut conn).await.unwrap_or_else(|| { + let data = match CONFIG.sso_master_password_policy() { + Some(policy) => policy, + None => "null".to_string(), + }; + + OrgPolicy::new(org_id, OrgPolicyType::MasterPassword, CONFIG.sso_master_password_policy().is_some(), data) + }); + + Ok(Json(policy.to_json())) +} + +#[get("/organizations//policies/", rank = 2)] async fn get_policy(org_id: OrganizationId, pol_type: i32, headers: AdminHeaders, mut conn: DbConn) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } + let Some(pol_type_enum) = OrgPolicyType::from_i32(pol_type) else { err!("Invalid or unsupported policy type") }; let policy = match OrgPolicy::find_by_org_and_type(&org_id, pol_type_enum, &mut conn).await { Some(p) => p, - None => OrgPolicy::new(org_id.clone(), pol_type_enum, "null".to_string()), + None => OrgPolicy::new(org_id.clone(), pol_type_enum, false, "null".to_string()), }; Ok(Json(policy.to_json())) @@ -2156,7 +2227,7 @@ async fn put_policy( let mut policy = match OrgPolicy::find_by_org_and_type(&org_id, pol_type_enum, &mut conn).await { Some(p) => p, - None => OrgPolicy::new(org_id.clone(), pol_type_enum, "{}".to_string()), + None => OrgPolicy::new(org_id.clone(), pol_type_enum, false, "{}".to_string()), }; policy.enabled = data.enabled; @@ -2315,7 +2386,8 @@ async fn import(org_id: OrganizationId, data: Json, headers: Head MembershipStatus::Accepted as i32 // Automatically mark user as accepted if no email invites }; - let mut new_member = Membership::new(user.uuid.clone(), org_id.clone()); + let mut new_member = + Membership::new(user.uuid.clone(), org_id.clone(), Some(headers.user.email.clone())); new_member.access_all = false; new_member.atype = MembershipType::User as i32; new_member.status = member_status; diff --git a/src/api/core/public.rs b/src/api/core/public.rs index 84606de6..46b59290 100644 --- a/src/api/core/public.rs +++ b/src/api/core/public.rs @@ -89,7 +89,7 @@ async fn ldap_import(data: Json, token: PublicToken, mut conn: Db Some(user) => user, // exists in vaultwarden None => { // User does not exist yet - let mut new_user = User::new(user_data.email.clone()); + let mut new_user = User::new(user_data.email.clone(), None); new_user.save(&mut conn).await?; if !CONFIG.mail_enabled() { @@ -105,7 +105,12 @@ async fn ldap_import(data: Json, token: PublicToken, mut conn: Db MembershipStatus::Accepted as i32 // Automatically mark user as accepted if no email invites }; - let mut new_member = Membership::new(user.uuid.clone(), org_id.clone()); + let (org_name, org_email) = match Organization::find_by_uuid(&org_id, &mut conn).await { + Some(org) => (org.name, org.billing_email), + None => err!("Error looking up organization"), + }; + + let mut new_member = Membership::new(user.uuid.clone(), org_id.clone(), Some(org_email.clone())); new_member.set_external_id(Some(user_data.external_id.clone())); new_member.access_all = false; new_member.atype = MembershipType::User as i32; @@ -114,11 +119,6 @@ async fn ldap_import(data: Json, token: PublicToken, mut conn: Db new_member.save(&mut conn).await?; if CONFIG.mail_enabled() { - let (org_name, org_email) = match Organization::find_by_uuid(&org_id, &mut conn).await { - Some(org) => (org.name, org.billing_email), - None => err!("Error looking up organization"), - }; - if let Err(e) = mail::send_invite(&user, org_id.clone(), new_member.uuid.clone(), &org_name, Some(org_email)).await { diff --git a/src/api/core/two_factor/email.rs b/src/api/core/two_factor/email.rs index 110856ab..46ff4599 100644 --- a/src/api/core/two_factor/email.rs +++ b/src/api/core/two_factor/email.rs @@ -10,7 +10,7 @@ use crate::{ auth::Headers, crypto, db::{ - models::{EventType, TwoFactor, TwoFactorType, User, UserId}, + models::{DeviceId, EventType, TwoFactor, TwoFactorType, User, UserId}, DbConn, }, error::{Error, MapResult}, @@ -24,11 +24,15 @@ pub fn routes() -> Vec { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct SendEmailLoginData { - // DeviceIdentifier: String, // Currently not used + device_identifier: DeviceId, + + #[allow(unused)] #[serde(alias = "Email")] - email: String, + email: Option, + + #[allow(unused)] #[serde(alias = "MasterPasswordHash")] - master_password_hash: String, + master_password_hash: Option, } /// User is trying to login and wants to use email 2FA. @@ -40,15 +44,10 @@ async fn send_email_login(data: Json, mut conn: DbConn) -> E use crate::db::models::User; // Get the user - let Some(user) = User::find_by_mail(&data.email, &mut conn).await else { - err!("Username or password is incorrect. Try again.") + let Some(user) = User::find_by_device_id(&data.device_identifier, &mut conn).await else { + err!("Cannot find user. Try again.") }; - // Check password - if !user.check_valid_password(&data.master_password_hash) { - err!("Username or password is incorrect. Try again.") - } - if !CONFIG._enable_email_2fa() { err!("Email 2FA is disabled") } diff --git a/src/api/identity.rs b/src/api/identity.rs index 6a6e52fc..de71c3cb 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -1,8 +1,10 @@ -use chrono::Utc; +use chrono::{NaiveDateTime, Utc}; use num_traits::FromPrimitive; -use rocket::serde::json::Json; use rocket::{ form::{Form, FromForm}, + http::Status, + response::Redirect, + serde::json::Json, Route, }; use serde_json::Value; @@ -10,7 +12,7 @@ use serde_json::Value; use crate::{ api::{ core::{ - accounts::{PreloginData, RegisterData, _prelogin, _register}, + accounts::{PreloginData, RegisterData, _prelogin, _register, kdf_upgrade}, log_user_event, two_factor::{authenticator, duo, duo_oidc, email, enforce_2fa_policy, webauthn, yubikey}, }, @@ -18,14 +20,28 @@ use crate::{ push::register_push_device, ApiResult, EmptyResult, JsonResult, }, - auth::{generate_organization_api_key_login_claims, ClientHeaders, ClientIp, ClientVersion}, + auth, + auth::{generate_organization_api_key_login_claims, AuthMethod, ClientHeaders, ClientIp, ClientVersion}, db::{models::*, DbConn}, error::MapResult, - mail, util, CONFIG, + mail, sso, + sso::{OIDCCode, OIDCState}, + util, CONFIG, }; pub fn routes() -> Vec { - routes![login, prelogin, identity_register, register_verification_email, register_finish] + routes![ + login, + prelogin, + identity_register, + register_verification_email, + register_finish, + _prevalidate, + prevalidate, + authorize, + oidcsignin, + oidcsignin_error + ] } #[post("/connect/token", data = "")] @@ -42,8 +58,9 @@ async fn login( let login_result = match data.grant_type.as_ref() { "refresh_token" => { _check_is_some(&data.refresh_token, "refresh_token cannot be blank")?; - _refresh_login(data, &mut conn).await + _refresh_login(data, &mut conn, &client_header.ip).await } + "password" if CONFIG.sso_enabled() && CONFIG.sso_only() => err!("SSO sign-in is required"), "password" => { _check_is_some(&data.client_id, "client_id cannot be blank")?; _check_is_some(&data.password, "password cannot be blank")?; @@ -67,6 +84,17 @@ async fn login( _api_key_login(data, &mut user_id, &mut conn, &client_header.ip).await } + "authorization_code" if CONFIG.sso_enabled() => { + _check_is_some(&data.client_id, "client_id cannot be blank")?; + _check_is_some(&data.code, "code cannot be blank")?; + + _check_is_some(&data.device_identifier, "device_identifier cannot be blank")?; + _check_is_some(&data.device_name, "device_name cannot be blank")?; + _check_is_some(&data.device_type, "device_type cannot be blank")?; + + _sso_login(data, &mut user_id, &mut conn, &client_header.ip, &client_version).await + } + "authorization_code" => err!("SSO sign-in is not available"), t => err!("Invalid type", t), }; @@ -100,37 +128,193 @@ async fn login( login_result } -async fn _refresh_login(data: ConnectData, conn: &mut DbConn) -> JsonResult { +// Return Status::Unauthorized to trigger logout +async fn _refresh_login(data: ConnectData, conn: &mut DbConn, ip: &ClientIp) -> JsonResult { // Extract token - let token = data.refresh_token.unwrap(); - - // Get device by refresh token - let mut device = Device::find_by_refresh_token(&token, conn).await.map_res("Invalid refresh token")?; - - let scope = "api offline_access"; - let scope_vec = vec!["api".into(), "offline_access".into()]; + let refresh_token = match data.refresh_token { + Some(token) => token, + None => err_code!("Missing refresh_token", Status::Unauthorized.code), + }; - // Common - let user = User::find_by_uuid(&device.user_uuid, conn).await.unwrap(); // --- // Disabled this variable, it was used to generate the JWT // Because this might get used in the future, and is add by the Bitwarden Server, lets keep it, but then commented out // See: https://github.com/dani-garcia/vaultwarden/issues/4156 // --- // let members = Membership::find_confirmed_by_user(&user.uuid, conn).await; - let (access_token, expires_in) = device.refresh_tokens(&user, scope_vec, data.client_id); - device.save(conn).await?; + match auth::refresh_tokens(ip, &refresh_token, data.client_id, conn).await { + Err(err) => { + err_code!(format!("Unable to refresh login credentials: {}", err.message()), Status::Unauthorized.code) + } + Ok((mut device, auth_tokens)) => { + // Save to update `device.updated_at` to track usage and toggle new status + device.save(conn).await?; + + let result = json!({ + "refresh_token": auth_tokens.refresh_token(), + "access_token": auth_tokens.access_token(), + "expires_in": auth_tokens.expires_in(), + "token_type": "Bearer", + "scope": auth_tokens.scope(), + }); + + Ok(Json(result)) + } + } +} - let result = json!({ - "access_token": access_token, - "expires_in": expires_in, - "token_type": "Bearer", - "refresh_token": device.refresh_token, +// After exchanging the code we need to check first if 2FA is needed before continuing +async fn _sso_login( + data: ConnectData, + user_id: &mut Option, + conn: &mut DbConn, + ip: &ClientIp, + client_version: &Option, +) -> JsonResult { + AuthMethod::Sso.check_scope(data.scope.as_ref())?; - "scope": scope, - }); + // Ratelimit the login + crate::ratelimit::check_limit_login(&ip.ip)?; - Ok(Json(result)) + let code = match data.code.as_ref() { + None => err!( + "Got no code in OIDC data", + ErrorEvent { + event: EventType::UserFailedLogIn + } + ), + Some(code) => code, + }; + + let user_infos = sso::exchange_code(code, conn).await?; + let user_with_sso = match SsoUser::find_by_identifier(&user_infos.identifier, conn).await { + None => match SsoUser::find_by_mail(&user_infos.email, conn).await { + None => None, + Some((user, Some(_))) => { + error!( + "Login failure ({}), existing SSO user ({}) with same email ({})", + user_infos.identifier, user.uuid, user.email + ); + err_silent!( + "Existing SSO user with same email", + ErrorEvent { + event: EventType::UserFailedLogIn + } + ) + } + Some((user, None)) if user.private_key.is_some() && !CONFIG.sso_signups_match_email() => { + error!( + "Login failure ({}), existing non SSO user ({}) with same email ({}) and association is disabled", + user_infos.identifier, user.uuid, user.email + ); + err_silent!( + "Existing non SSO user with same email", + ErrorEvent { + event: EventType::UserFailedLogIn + } + ) + } + Some((user, None)) => Some((user, None)), + }, + Some((user, sso_user)) => Some((user, Some(sso_user))), + }; + + let now = Utc::now().naive_utc(); + // Will trigger 2FA flow if needed + let (user, mut device, twofactor_token, sso_user) = match user_with_sso { + None => { + if !CONFIG.is_email_domain_allowed(&user_infos.email) { + err!( + "Email domain not allowed", + ErrorEvent { + event: EventType::UserFailedLogIn + } + ); + } + + match user_infos.email_verified { + None if !CONFIG.sso_allow_unknown_email_verification() => err!( + "Your provider does not send email verification status.\n\ + You will need to change the server configuration (check `SSO_ALLOW_UNKNOWN_EMAIL_VERIFICATION`) to log in.", + ErrorEvent { + event: EventType::UserFailedLogIn + } + ), + Some(false) => err!( + "You need to verify your email with your provider before you can log in", + ErrorEvent { + event: EventType::UserFailedLogIn + } + ), + _ => (), + } + + let mut user = User::new(user_infos.email, user_infos.user_name); + user.verified_at = Some(now); + user.save(conn).await?; + + let device = get_device(&data, conn, &user).await?; + + (user, device, None, None) + } + Some((user, _)) if !user.enabled => { + err!( + "This user has been disabled", + format!("IP: {}. Username: {}.", ip.ip, user.name), + ErrorEvent { + event: EventType::UserFailedLogIn + } + ) + } + Some((mut user, sso_user)) => { + let mut device = get_device(&data, conn, &user).await?; + let twofactor_token = twofactor_auth(&user, &data, &mut device, ip, client_version, conn).await?; + + if user.private_key.is_none() { + // User was invited a stub was created + user.verified_at = Some(now); + if let Some(user_name) = user_infos.user_name { + user.name = user_name; + } + + user.save(conn).await?; + } + + if user.email != user_infos.email { + if CONFIG.mail_enabled() { + mail::send_sso_change_email(&user_infos.email).await?; + } + info!("User {} email changed in SSO provider from {} to {}", user.uuid, user.email, user_infos.email); + } + + (user, device, twofactor_token, sso_user) + } + }; + + // We passed 2FA get full user informations + let auth_user = sso::redeem(&user_infos.state, conn).await?; + + if sso_user.is_none() { + let user_sso = SsoUser { + user_uuid: user.uuid.clone(), + identifier: user_infos.identifier, + }; + user_sso.save(conn).await?; + } + + // Set the user_uuid here to be passed back used for event logging. + *user_id = Some(user.uuid.clone()); + + let auth_tokens = sso::create_auth_tokens( + &device, + &user, + data.client_id, + auth_user.refresh_token, + auth_user.access_token, + auth_user.expires_in, + )?; + + authenticated_response(&user, &mut device, auth_tokens, twofactor_token, &now, conn, ip).await } async fn _password_login( @@ -141,11 +325,7 @@ async fn _password_login( client_version: &Option, ) -> JsonResult { // Validate scope - let scope = data.scope.as_ref().unwrap(); - if scope != "api offline_access" { - err!("Scope not supported") - } - let scope_vec = vec!["api".into(), "offline_access".into()]; + AuthMethod::Password.check_scope(data.scope.as_ref())?; // Ratelimit the login crate::ratelimit::check_limit_login(&ip.ip)?; @@ -212,13 +392,8 @@ async fn _password_login( } // Change the KDF Iterations (only when not logging in with an auth request) - if data.auth_request.is_none() && user.password_iterations != CONFIG.password_iterations() { - user.password_iterations = CONFIG.password_iterations(); - user.set_password(password, None, false, None); - - if let Err(e) = user.save(conn).await { - error!("Error updating user: {e:#?}"); - } + if data.auth_request.is_none() { + kdf_upgrade(&mut user, password, conn).await?; } let now = Utc::now().naive_utc(); @@ -255,12 +430,27 @@ async fn _password_login( ) } - let (mut device, new_device) = get_device(&data, conn, &user).await; + let mut device = get_device(&data, conn, &user).await?; let twofactor_token = twofactor_auth(&user, &data, &mut device, ip, client_version, conn).await?; - if CONFIG.mail_enabled() && new_device { - if let Err(e) = mail::send_new_device_logged_in(&user.email, &ip.ip.to_string(), &now, &device).await { + let auth_tokens = auth::AuthTokens::new(&device, &user, AuthMethod::Password, data.client_id); + + authenticated_response(&user, &mut device, auth_tokens, twofactor_token, &now, conn, ip).await +} + +#[allow(clippy::too_many_arguments)] +async fn authenticated_response( + user: &User, + device: &mut Device, + auth_tokens: auth::AuthTokens, + twofactor_token: Option, + now: &NaiveDateTime, + conn: &mut DbConn, + ip: &ClientIp, +) -> JsonResult { + if CONFIG.mail_enabled() && device.is_new() { + if let Err(e) = mail::send_new_device_logged_in(&user.email, &ip.ip.to_string(), now, device).await { error!("Error sending new device email: {e:#?}"); if CONFIG.require_device_email() { @@ -275,31 +465,21 @@ async fn _password_login( } // register push device - if !new_device { - register_push_device(&mut device, conn).await?; + if !device.is_new() { + register_push_device(device, conn).await?; } - // Common - // --- - // Disabled this variable, it was used to generate the JWT - // Because this might get used in the future, and is add by the Bitwarden Server, lets keep it, but then commented out - // See: https://github.com/dani-garcia/vaultwarden/issues/4156 - // --- - // let members = Membership::find_confirmed_by_user(&user.uuid, conn).await; - let (access_token, expires_in) = device.refresh_tokens(&user, scope_vec, data.client_id); + // Save to update `device.updated_at` to track usage and toggle new status device.save(conn).await?; - let master_password_policy = master_password_policy(&user, conn).await; + let master_password_policy = master_password_policy(user, conn).await; let mut result = json!({ - "access_token": access_token, - "expires_in": expires_in, + "access_token": auth_tokens.access_token(), + "expires_in": auth_tokens.expires_in(), "token_type": "Bearer", - "refresh_token": device.refresh_token, - "Key": user.akey, + "refresh_token": auth_tokens.refresh_token(), "PrivateKey": user.private_key, - //"TwoFactorToken": "11122233333444555666777888999" - "Kdf": user.client_kdf_type, "KdfIterations": user.client_kdf_iter, "KdfMemory": user.client_kdf_memory, @@ -307,19 +487,22 @@ async fn _password_login( "ResetMasterPassword": false, // TODO: Same as above "ForcePasswordReset": false, "MasterPasswordPolicy": master_password_policy, - - "scope": scope, + "scope": auth_tokens.scope(), "UserDecryptionOptions": { "HasMasterPassword": !user.password_hash.is_empty(), "Object": "userDecryptionOptions" }, }); + if !user.akey.is_empty() { + result["Key"] = Value::String(user.akey.clone()); + } + if let Some(token) = twofactor_token { result["TwoFactorToken"] = Value::String(token); } - info!("User {username} logged in successfully. IP: {}", ip.ip); + info!("User {} logged in successfully. IP: {}", &user.name, ip.ip); Ok(Json(result)) } @@ -333,9 +516,9 @@ async fn _api_key_login( crate::ratelimit::check_limit_login(&ip.ip)?; // Validate scope - match data.scope.as_ref().unwrap().as_ref() { - "api" => _user_api_key_login(data, user_id, conn, ip).await, - "api.organization" => _organization_api_key_login(data, conn, ip).await, + match data.scope.as_ref() { + Some(scope) if scope == &AuthMethod::UserApiKey.scope() => _user_api_key_login(data, user_id, conn, ip).await, + Some(scope) if scope == &AuthMethod::OrgApiKey.scope() => _organization_api_key_login(data, conn, ip).await, _ => err!("Scope not supported"), } } @@ -382,9 +565,9 @@ async fn _user_api_key_login( ) } - let (mut device, new_device) = get_device(&data, conn, &user).await; + let mut device = get_device(&data, conn, &user).await?; - if CONFIG.mail_enabled() && new_device { + if CONFIG.mail_enabled() && device.is_new() { let now = Utc::now().naive_utc(); if let Err(e) = mail::send_new_device_logged_in(&user.email, &ip.ip.to_string(), &now, &device).await { error!("Error sending new device email: {e:#?}"); @@ -400,15 +583,15 @@ async fn _user_api_key_login( } } - // Common - let scope_vec = vec!["api".into()]; // --- // Disabled this variable, it was used to generate the JWT // Because this might get used in the future, and is add by the Bitwarden Server, lets keep it, but then commented out // See: https://github.com/dani-garcia/vaultwarden/issues/4156 // --- - // let members = Membership::find_confirmed_by_user(&user.uuid, conn).await; - let (access_token, expires_in) = device.refresh_tokens(&user, scope_vec, data.client_id); + // let orgs = Membership::find_confirmed_by_user(&user.uuid, conn).await; + let access_claims = auth::LoginJwtClaims::default(&device, &user, &AuthMethod::UserApiKey, data.client_id); + + // Save to update `device.updated_at` to track usage and toggle new status device.save(conn).await?; info!("User {} logged in successfully via API key. IP: {}", user.email, ip.ip); @@ -416,8 +599,8 @@ async fn _user_api_key_login( // Note: No refresh_token is returned. The CLI just repeats the // client_credentials login flow when the existing token expires. let result = json!({ - "access_token": access_token, - "expires_in": expires_in, + "access_token": access_claims.token(), + "expires_in": access_claims.expires_in(), "token_type": "Bearer", "Key": user.akey, "PrivateKey": user.private_key, @@ -427,7 +610,7 @@ async fn _user_api_key_login( "KdfMemory": user.client_kdf_memory, "KdfParallelism": user.client_kdf_parallelism, "ResetMasterPassword": false, // TODO: according to official server seems something like: user.password_hash.is_empty(), but would need testing - "scope": "api", + "scope": AuthMethod::UserApiKey.scope(), }); Ok(Json(result)) @@ -451,35 +634,29 @@ async fn _organization_api_key_login(data: ConnectData, conn: &mut DbConn, ip: & } let claim = generate_organization_api_key_login_claims(org_api_key.uuid, org_api_key.org_uuid); - let access_token = crate::auth::encode_jwt(&claim); + let access_token = auth::encode_jwt(&claim); Ok(Json(json!({ "access_token": access_token, "expires_in": 3600, "token_type": "Bearer", - "scope": "api.organization", + "scope": AuthMethod::OrgApiKey.scope(), }))) } /// Retrieves an existing device or creates a new device from ConnectData and the User -async fn get_device(data: &ConnectData, conn: &mut DbConn, user: &User) -> (Device, bool) { +async fn get_device(data: &ConnectData, conn: &mut DbConn, user: &User) -> ApiResult { // On iOS, device_type sends "iOS", on others it sends a number // When unknown or unable to parse, return 14, which is 'Unknown Browser' let device_type = util::try_parse_string(data.device_type.as_ref()).unwrap_or(14); let device_id = data.device_identifier.clone().expect("No device id provided"); let device_name = data.device_name.clone().expect("No device name provided"); - let mut new_device = false; // Find device or create new - let device = match Device::find_by_uuid_and_user(&device_id, &user.uuid, conn).await { - Some(device) => device, - None => { - new_device = true; - Device::new(device_id, user.uuid.clone(), device_name, device_type) - } - }; - - (device, new_device) + match Device::find_by_uuid_and_user(&device_id, &user.uuid, conn).await { + Some(device) => Ok(device), + None => Device::new(device_id, user.uuid.clone(), device_name, device_type, conn).await, + } } async fn twofactor_auth( @@ -572,12 +749,13 @@ async fn twofactor_auth( TwoFactorIncomplete::mark_complete(&user.uuid, &device.uuid, conn).await?; - if !CONFIG.disable_2fa_remember() && remember == 1 { - Ok(Some(device.refresh_twofactor_remember())) + let two_factor = if !CONFIG.disable_2fa_remember() && remember == 1 { + Some(device.refresh_twofactor_remember()) } else { device.delete_twofactor_remember(); - Ok(None) - } + None + }; + Ok(two_factor) } fn _selected_data(tf: Option) -> ApiResult { @@ -727,9 +905,8 @@ async fn register_verification_email( let should_send_mail = CONFIG.mail_enabled() && CONFIG.signups_verify(); - let token_claims = - crate::auth::generate_register_verify_claims(data.email.clone(), data.name.clone(), should_send_mail); - let token = crate::auth::encode_jwt(&token_claims); + let token_claims = auth::generate_register_verify_claims(data.email.clone(), data.name.clone(), should_send_mail); + let token = auth::encode_jwt(&token_claims); if should_send_mail { let user = User::find_by_mail(&data.email, &mut conn).await; @@ -812,11 +989,137 @@ struct ConnectData { two_factor_remember: Option, #[field(name = uncased("authrequest"))] auth_request: Option, + // Needed for authorization code + #[form(field = uncased("code"))] + code: Option, } - fn _check_is_some(value: &Option, msg: &str) -> EmptyResult { if value.is_none() { err!(msg) } Ok(()) } + +// Deprecated but still needed for Mobile apps +#[get("/account/prevalidate")] +fn _prevalidate() -> JsonResult { + prevalidate() +} + +#[get("/sso/prevalidate")] +fn prevalidate() -> JsonResult { + if CONFIG.sso_enabled() { + let sso_token = sso::encode_ssotoken_claims(); + Ok(Json(json!({ + "token": sso_token, + }))) + } else { + err!("SSO sign-in is not available") + } +} + +#[get("/connect/oidc-signin?&", rank = 1)] +async fn oidcsignin(code: OIDCCode, state: String, conn: DbConn) -> ApiResult { + oidcsignin_redirect( + state, + |decoded_state| sso::OIDCCodeWrapper::Ok { + state: decoded_state, + code, + }, + &conn, + ) + .await +} + +// Bitwarden client appear to only care for code and state so we pipe it through +// cf: https://github.com/bitwarden/clients/blob/8e46ef1ae5be8b62b0d3d0b9d1b1c62088a04638/libs/angular/src/auth/components/sso.component.ts#L68C11-L68C23) +#[get("/connect/oidc-signin?&&", rank = 2)] +async fn oidcsignin_error( + state: String, + error: String, + error_description: Option, + conn: DbConn, +) -> ApiResult { + oidcsignin_redirect( + state, + |decoded_state| sso::OIDCCodeWrapper::Error { + state: decoded_state, + error, + error_description, + }, + &conn, + ) + .await +} + +// The state was encoded using Base64 to ensure no issue with providers. +// iss and scope parameters are needed for redirection to work on IOS. +async fn oidcsignin_redirect( + base64_state: String, + wrapper: impl FnOnce(OIDCState) -> sso::OIDCCodeWrapper, + conn: &DbConn, +) -> ApiResult { + let state = sso::deocde_state(base64_state)?; + let code = sso::encode_code_claims(wrapper(state.clone())); + + let nonce = match SsoNonce::find(&state, conn).await { + Some(n) => n, + None => err!(format!("Failed to retrive redirect_uri with {state}")), + }; + + let mut url = match url::Url::parse(&nonce.redirect_uri) { + Ok(url) => url, + Err(err) => err!(format!("Failed to parse redirect uri ({}): {err}", nonce.redirect_uri)), + }; + + url.query_pairs_mut() + .append_pair("code", &code) + .append_pair("state", &state) + .append_pair("scope", &AuthMethod::Sso.scope()) + .append_pair("iss", &CONFIG.domain()); + + debug!("Redirection to {url}"); + + Ok(Redirect::temporary(String::from(url))) +} + +#[derive(Debug, Clone, Default, FromForm)] +struct AuthorizeData { + #[field(name = uncased("client_id"))] + #[field(name = uncased("clientid"))] + client_id: String, + #[field(name = uncased("redirect_uri"))] + #[field(name = uncased("redirecturi"))] + redirect_uri: String, + #[allow(unused)] + response_type: Option, + #[allow(unused)] + scope: Option, + state: OIDCState, + #[allow(unused)] + code_challenge: Option, + #[allow(unused)] + code_challenge_method: Option, + #[allow(unused)] + response_mode: Option, + #[allow(unused)] + domain_hint: Option, + #[allow(unused)] + #[field(name = uncased("ssoToken"))] + sso_token: Option, +} + +// The `redirect_uri` will change depending of the client (web, android, ios ..) +#[get("/connect/authorize?")] +async fn authorize(data: AuthorizeData, conn: DbConn) -> ApiResult { + let AuthorizeData { + client_id, + redirect_uri, + state, + .. + } = data; + + let auth_url = sso::authorize_url(state, &client_id, &redirect_uri, conn).await?; + + Ok(Redirect::temporary(String::from(auth_url))) +} diff --git a/src/api/mod.rs b/src/api/mod.rs index dd68aabe..e0df1e64 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -36,9 +36,10 @@ use crate::db::{ models::{OrgPolicy, OrgPolicyType, User}, DbConn, }; +use crate::CONFIG; // Type aliases for API methods results -type ApiResult = Result; +pub type ApiResult = Result; pub type JsonResult = ApiResult>; pub type EmptyResult = ApiResult<()>; @@ -109,6 +110,8 @@ async fn master_password_policy(user: &User, conn: &DbConn) -> Value { enforce_on_login: acc.enforce_on_login || policy.enforce_on_login, } })) + } else if let Some(policy_str) = CONFIG.sso_master_password_policy().filter(|_| CONFIG.sso_enabled()) { + serde_json::from_str(&policy_str).unwrap_or(json!({})) } else { json!({}) }; diff --git a/src/api/web.rs b/src/api/web.rs index c4faf58c..a6d9f0c2 100644 --- a/src/api/web.rs +++ b/src/api/web.rs @@ -55,13 +55,15 @@ fn not_found() -> ApiResult> { #[get("/css/vaultwarden.css")] fn vaultwarden_css() -> Cached> { let css_options = json!({ - "signup_disabled": !CONFIG.signups_allowed() && CONFIG.signups_domains_whitelist().is_empty(), - "mail_enabled": CONFIG.mail_enabled(), - "mail_2fa_enabled": CONFIG._enable_email_2fa(), - "yubico_enabled": CONFIG._enable_yubico() && CONFIG.yubico_client_id().is_some() && CONFIG.yubico_secret_key().is_some(), "emergency_access_allowed": CONFIG.emergency_access_allowed(), - "sends_allowed": CONFIG.sends_allowed(), "load_user_scss": true, + "mail_2fa_enabled": CONFIG._enable_email_2fa(), + "mail_enabled": CONFIG.mail_enabled(), + "sends_allowed": CONFIG.sends_allowed(), + "signup_disabled": !CONFIG.signups_allowed() && CONFIG.signups_domains_whitelist().is_empty(), + "sso_disabled": !CONFIG.sso_enabled(), + "sso_only": CONFIG.sso_enabled() && CONFIG.sso_only(), + "yubico_enabled": CONFIG._enable_yubico() && CONFIG.yubico_client_id().is_some() && CONFIG.yubico_secret_key().is_some(), }); let scss = match CONFIG.render_template("scss/vaultwarden.scss", &css_options) { diff --git a/src/auth.rs b/src/auth.rs index 2d51d539..1a602a5c 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,6 +1,5 @@ // JWT Handling -// -use chrono::{TimeDelta, Utc}; +use chrono::{DateTime, TimeDelta, Utc}; use jsonwebtoken::{errors::ErrorKind, Algorithm, DecodingKey, EncodingKey, Header}; use num_traits::FromPrimitive; use once_cell::sync::{Lazy, OnceCell}; @@ -10,17 +9,24 @@ use serde::ser::Serialize; use std::{env, net::IpAddr}; use crate::{ + api::ApiResult, config::PathType, db::models::{ - AttachmentId, CipherId, CollectionId, DeviceId, EmergencyAccessId, MembershipId, OrgApiKeyId, OrganizationId, - SendFileId, SendId, UserId, + AttachmentId, CipherId, CollectionId, DeviceId, DeviceType, EmergencyAccessId, MembershipId, OrgApiKeyId, + OrganizationId, SendFileId, SendId, UserId, }, + error::Error, + sso, CONFIG, }; -use crate::{error::Error, CONFIG}; const JWT_ALGORITHM: Algorithm = Algorithm::RS256; -pub static DEFAULT_VALIDITY: Lazy = Lazy::new(|| TimeDelta::try_hours(2).unwrap()); +// Limit when BitWarden consider the token as expired +pub static BW_EXPIRATION: Lazy = Lazy::new(|| TimeDelta::try_minutes(5).unwrap()); + +pub static DEFAULT_REFRESH_VALIDITY: Lazy = Lazy::new(|| TimeDelta::try_days(30).unwrap()); +pub static MOBILE_REFRESH_VALIDITY: Lazy = Lazy::new(|| TimeDelta::try_days(90).unwrap()); +pub static DEFAULT_ACCESS_VALIDITY: Lazy = Lazy::new(|| TimeDelta::try_hours(2).unwrap()); static JWT_HEADER: Lazy
= Lazy::new(|| Header::new(JWT_ALGORITHM)); pub static JWT_LOGIN_ISSUER: Lazy = Lazy::new(|| format!("{}|login", CONFIG.domain_origin())); @@ -85,7 +91,7 @@ pub fn encode_jwt(claims: &T) -> String { } } -fn decode_jwt(token: &str, issuer: String) -> Result { +pub fn decode_jwt(token: &str, issuer: String) -> Result { let mut validation = jsonwebtoken::Validation::new(JWT_ALGORITHM); validation.leeway = 30; // 30 seconds validation.validate_exp = true; @@ -99,11 +105,15 @@ fn decode_jwt(token: &str, issuer: String) -> Result err!("Token is invalid"), ErrorKind::InvalidIssuer => err!("Issuer is invalid"), ErrorKind::ExpiredSignature => err!("Token has expired"), - _ => err!("Error decoding JWT"), + _ => err!(format!("Error decoding JWT: {:?}", err)), }, } } +pub fn decode_refresh(token: &str) -> Result { + decode_jwt(token, JWT_LOGIN_ISSUER.to_string()) +} + pub fn decode_login(token: &str) -> Result { decode_jwt(token, JWT_LOGIN_ISSUER.to_string()) } @@ -186,6 +196,84 @@ pub struct LoginJwtClaims { pub amr: Vec, } +impl LoginJwtClaims { + pub fn new( + device: &Device, + user: &User, + nbf: i64, + exp: i64, + scope: Vec, + client_id: Option, + now: DateTime, + ) -> Self { + // --- + // Disabled these keys to be added to the JWT since they could cause the JWT to get too large + // Also These key/value pairs are not used anywhere by either Vaultwarden or Bitwarden Clients + // Because these might get used in the future, and they are added by the Bitwarden Server, lets keep it, but then commented out + // --- + // fn arg: orgs: Vec, + // --- + // let orgowner: Vec<_> = orgs.iter().filter(|o| o.atype == 0).map(|o| o.org_uuid.clone()).collect(); + // let orgadmin: Vec<_> = orgs.iter().filter(|o| o.atype == 1).map(|o| o.org_uuid.clone()).collect(); + // let orguser: Vec<_> = orgs.iter().filter(|o| o.atype == 2).map(|o| o.org_uuid.clone()).collect(); + // let orgmanager: Vec<_> = orgs.iter().filter(|o| o.atype == 3).map(|o| o.org_uuid.clone()).collect(); + + if exp <= (now + *BW_EXPIRATION).timestamp() { + warn!("Raise access_token lifetime to more than 5min.") + } + + // Create the JWT claims struct, to send to the client + Self { + nbf, + exp, + iss: JWT_LOGIN_ISSUER.to_string(), + sub: user.uuid.clone(), + premium: true, + name: user.name.clone(), + email: user.email.clone(), + email_verified: !CONFIG.mail_enabled() || user.verified_at.is_some(), + + // --- + // Disabled these keys to be added to the JWT since they could cause the JWT to get too large + // Also These key/value pairs are not used anywhere by either Vaultwarden or Bitwarden Clients + // Because these might get used in the future, and they are added by the Bitwarden Server, lets keep it, but then commented out + // See: https://github.com/dani-garcia/vaultwarden/issues/4156 + // --- + // orgowner, + // orgadmin, + // orguser, + // orgmanager, + sstamp: user.security_stamp.clone(), + device: device.uuid.clone(), + devicetype: DeviceType::from_i32(device.atype).to_string(), + client_id: client_id.unwrap_or("undefined".to_string()), + scope, + amr: vec!["Application".into()], + } + } + + pub fn default(device: &Device, user: &User, auth_method: &AuthMethod, client_id: Option) -> Self { + let time_now = Utc::now(); + Self::new( + device, + user, + time_now.timestamp(), + (time_now + *DEFAULT_ACCESS_VALIDITY).timestamp(), + auth_method.scope_vec(), + client_id, + time_now, + ) + } + + pub fn token(&self) -> String { + encode_jwt(&self) + } + + pub fn expires_in(&self) -> i64 { + self.exp - Utc::now().timestamp() + } +} + #[derive(Debug, Serialize, Deserialize)] pub struct InviteJwtClaims { // Not before @@ -1001,3 +1089,153 @@ impl<'r> FromRequest<'r> for ClientVersion { Outcome::Success(ClientVersion(version)) } } + +#[derive(Clone, Debug, Ord, PartialOrd, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AuthMethod { + OrgApiKey, + Password, + Sso, + UserApiKey, +} + +impl AuthMethod { + pub fn scope(&self) -> String { + match self { + AuthMethod::OrgApiKey => "api.organization".to_string(), + AuthMethod::Password => "api offline_access".to_string(), + AuthMethod::Sso => "api offline_access".to_string(), + AuthMethod::UserApiKey => "api".to_string(), + } + } + + pub fn scope_vec(&self) -> Vec { + self.scope().split_whitespace().map(str::to_string).collect() + } + + pub fn check_scope(&self, scope: Option<&String>) -> ApiResult { + let method_scope = self.scope(); + match scope { + None => err!("Missing scope"), + Some(scope) if scope == &method_scope => Ok(method_scope), + Some(scope) => err!(format!("Scope ({scope}) not supported")), + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub enum TokenWrapper { + Access(String), + Refresh(String), +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct RefreshJwtClaims { + // Not before + pub nbf: i64, + // Expiration time + pub exp: i64, + // Issuer + pub iss: String, + // Subject + pub sub: AuthMethod, + + pub device_token: String, + + pub token: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct AuthTokens { + pub refresh_claims: RefreshJwtClaims, + pub access_claims: LoginJwtClaims, +} + +impl AuthTokens { + pub fn refresh_token(&self) -> String { + encode_jwt(&self.refresh_claims) + } + + pub fn access_token(&self) -> String { + self.access_claims.token() + } + + pub fn expires_in(&self) -> i64 { + self.access_claims.expires_in() + } + + pub fn scope(&self) -> String { + self.refresh_claims.sub.scope() + } + + // Create refresh_token and access_token with default validity + pub fn new(device: &Device, user: &User, sub: AuthMethod, client_id: Option) -> Self { + let time_now = Utc::now(); + + let access_claims = LoginJwtClaims::default(device, user, &sub, client_id); + + let validity = if DeviceType::is_mobile(&device.atype) { + *MOBILE_REFRESH_VALIDITY + } else { + *DEFAULT_REFRESH_VALIDITY + }; + + let refresh_claims = RefreshJwtClaims { + nbf: time_now.timestamp(), + exp: (time_now + validity).timestamp(), + iss: JWT_LOGIN_ISSUER.to_string(), + sub, + device_token: device.refresh_token.clone(), + token: None, + }; + + Self { + refresh_claims, + access_claims, + } + } +} + +pub async fn refresh_tokens( + ip: &ClientIp, + refresh_token: &str, + client_id: Option, + conn: &mut DbConn, +) -> ApiResult<(Device, AuthTokens)> { + let refresh_claims = match decode_refresh(refresh_token) { + Err(err) => { + debug!("Failed to decode {} refresh_token: {refresh_token}", ip.ip); + err_silent!(format!("Impossible to read refresh_token: {}", err.message())) + } + Ok(claims) => claims, + }; + + // Get device by refresh token + let mut device = match Device::find_by_refresh_token(&refresh_claims.device_token, conn).await { + None => err!("Invalid refresh token"), + Some(device) => device, + }; + + // Save to update `updated_at`. + device.save(conn).await?; + + let user = match User::find_by_uuid(&device.user_uuid, conn).await { + None => err!("Impossible to find user"), + Some(user) => user, + }; + + let auth_tokens = match refresh_claims.sub { + AuthMethod::Sso if CONFIG.sso_enabled() && CONFIG.sso_auth_only_not_session() => { + AuthTokens::new(&device, &user, refresh_claims.sub, client_id) + } + AuthMethod::Sso if CONFIG.sso_enabled() => { + sso::exchange_refresh_token(&device, &user, client_id, refresh_claims).await? + } + AuthMethod::Sso => err!("SSO is now disabled, Login again using email and master password"), + AuthMethod::Password if CONFIG.sso_enabled() && CONFIG.sso_only() => err!("SSO is now required, Login again"), + AuthMethod::Password => AuthTokens::new(&device, &user, refresh_claims.sub, client_id), + _ => err!("Invalid auth method, cannot refresh token"), + }; + + Ok((device, auth_tokens)) +} diff --git a/src/config.rs b/src/config.rs index 5a3d060f..ea2b3b87 100644 --- a/src/config.rs +++ b/src/config.rs @@ -458,6 +458,9 @@ make_config! { /// Duo Auth context cleanup schedule |> Cron schedule of the job that cleans expired Duo contexts from the database. Does nothing if Duo MFA is disabled or set to use the legacy iframe prompt. /// Defaults to once every minute. Set blank to disable this job. duo_context_purge_schedule: String, false, def, "30 * * * * *".to_string(); + /// Purge incomplete sso nonce. |> Cron schedule of the job that cleans leftover nonce in db due to incomplete sso login. + /// Defaults to daily. Set blank to disable this job. + purge_incomplete_sso_nonce: String, false, def, "0 20 0 * * *".to_string(); }, /// General settings @@ -676,6 +679,42 @@ make_config! { enforce_single_org_with_reset_pw_policy: bool, false, def, false; }, + /// OpenID Connect SSO settings + sso { + /// Enabled + sso_enabled: bool, false, def, false; + /// Only sso login |> Disable Email+Master Password login + sso_only: bool, true, def, false; + /// Allow email association |> Associate existing non-sso user based on email + sso_signups_match_email: bool, true, def, true; + /// Allow unknown email verification status |> Allowing this with `SSO_SIGNUPS_MATCH_EMAIL=true` open potential account takeover. + sso_allow_unknown_email_verification: bool, false, def, false; + /// Client ID + sso_client_id: String, false, def, String::new(); + /// Client Key + sso_client_secret: Pass, false, def, String::new(); + /// Authority Server |> Base url of the OIDC provider discovery endpoint (without `/.well-known/openid-configuration`) + sso_authority: String, false, def, String::new(); + /// Authorization request scopes |> List the of the needed scope (`openid` is implicit) + sso_scopes: String, false, def, "email profile".to_string(); + /// Authorization request extra parameters + sso_authorize_extra_params: String, false, def, String::new(); + /// Use PKCE during Authorization flow + sso_pkce: bool, false, def, true; + /// Regex for additionnal trusted Id token audience |> By default only the client_id is trsuted. + sso_audience_trusted: String, false, option; + /// CallBack Path |> Generated from Domain. + sso_callback_path: String, false, generated, |c| generate_sso_callback_path(&c.domain); + /// Optional sso master password policy |> Ex format: '{"enforceOnLogin":false,"minComplexity":3,"minLength":12,"requireLower":false,"requireNumbers":false,"requireSpecial":false,"requireUpper":false}' + sso_master_password_policy: String, true, option; + /// Use sso only for auth not the session lifecycle |> Use default Vaultwarden session lifecycle (Idle refresh token valid for 30days) + sso_auth_only_not_session: bool, true, def, false; + /// Client cache for discovery endpoint. |> Duration in seconds (0 or less to disable). More details: https://github.com/dani-garcia/vaultwarden/blob/sso-support/SSO.md#client-cache + sso_client_cache_expiration: u64, true, def, 0; + /// Log all tokens |> `LOG_LEVEL=debug` or `LOG_LEVEL=info,vaultwarden::sso=debug` is required + sso_debug_tokens: bool, true, def, false; + }, + /// Yubikey settings yubico: _enable_yubico { /// Enabled @@ -911,6 +950,17 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> { err!("All Duo options need to be set for global Duo support") } + if cfg.sso_enabled { + if cfg.sso_client_id.is_empty() || cfg.sso_client_secret.is_empty() || cfg.sso_authority.is_empty() { + err!("`SSO_CLIENT_ID`, `SSO_CLIENT_SECRET` and `SSO_AUTHORITY` must be set for SSO support") + } + + internal_sso_issuer_url(&cfg.sso_authority)?; + internal_sso_redirect_url(&cfg.sso_callback_path)?; + check_master_password_policy(&cfg.sso_master_password_policy)?; + internal_sso_authorize_extra_params_vec(&cfg.sso_authorize_extra_params)?; + } + if cfg._enable_yubico { if cfg.yubico_client_id.is_some() != cfg.yubico_secret_key.is_some() { err!("Both `YUBICO_CLIENT_ID` and `YUBICO_SECRET_KEY` must be set for Yubikey OTP support") @@ -1088,6 +1138,35 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> { Ok(()) } +fn internal_sso_issuer_url(sso_authority: &String) -> Result { + match openidconnect::IssuerUrl::new(sso_authority.clone()) { + Err(err) => err!(format!("Invalid sso_authority UR ({sso_authority}): {err}")), + Ok(issuer_url) => Ok(issuer_url), + } +} + +fn internal_sso_redirect_url(sso_callback_path: &String) -> Result { + match openidconnect::RedirectUrl::new(sso_callback_path.clone()) { + Err(err) => err!(format!("Invalid sso_callback_path ({sso_callback_path} built using `domain`) URL: {err}")), + Ok(redirect_url) => Ok(redirect_url), + } +} + +fn internal_sso_authorize_extra_params_vec(config: &str) -> Result, Error> { + match parse_param_list(config.to_owned(), '&', '=') { + Err(e) => err!(format!("Invalid SSO_AUTHORIZE_EXTRA_PARAMS: {e}")), + Ok(params) => Ok(params), + } +} + +fn check_master_password_policy(sso_master_password_policy: &Option) -> Result<(), Error> { + let policy = sso_master_password_policy.as_ref().map(|mpp| serde_json::from_str::(mpp)); + if let Some(Err(error)) = policy { + err!(format!("Invalid sso_master_password_policy ({error}), Ensure that it's correctly escaped with ''")) + } + Ok(()) +} + /// Extracts an RFC 6454 web origin from a URL. fn extract_url_origin(url: &str) -> String { match Url::parse(url) { @@ -1119,6 +1198,10 @@ fn generate_smtp_img_src(embed_images: bool, domain: &str) -> String { } } +fn generate_sso_callback_path(domain: &str) -> String { + format!("{domain}/identity/connect/oidc-signin") +} + /// Generate the correct URL for the icon service. /// This will be used within icons.rs to call the external icon service. fn generate_icon_service_url(icon_service: &str) -> String { @@ -1161,6 +1244,26 @@ fn smtp_convert_deprecated_ssl_options(smtp_ssl: Option, smtp_explicit_tls "starttls".to_string() } +/// Allow to parse a list of Key/Values (Ex: `key1=value&key2=value2`) +/// - line break are handled as `separator` +fn parse_param_list(config: String, separator: char, kv_separator: char) -> Result, Error> { + config + .lines() + .flat_map(|l| l.split(separator)) + .map(|l| l.trim()) + .filter(|l| !l.is_empty()) + .map(|l| { + let split = l.split(kv_separator).collect::>(); + match &split[..] { + [key, value] => Ok(((*key).to_string(), (*value).to_string())), + _ => { + err!(format!("Failed to parse ({l}). Expected key{kv_separator}value")) + } + } + }) + .collect() +} + fn opendal_operator_for_path(path: &str) -> Result { // Cache of previously built operators by path static OPERATORS_BY_PATH: LazyLock> = @@ -1328,6 +1431,14 @@ impl Config { self.update_config(builder, false).await } + // The `signups_allowed` setting is overrided if: + // - The email whitelist is not empty (will allow signups). + // - The sso is activated and password login is disabled (will disable signups). + pub fn is_signup_disabled(&self) -> bool { + (!self.signups_allowed() && self.signups_domains_whitelist().is_empty()) + || (self.sso_enabled() && self.sso_only()) + } + /// Tests whether an email's domain is allowed. A domain is allowed if it /// is in signups_domains_whitelist, or if no whitelist is set (so there /// are no domain restrictions in effect). @@ -1346,12 +1457,7 @@ impl Config { /// Tests whether signup is allowed for an email address, taking into /// account the signups_allowed and signups_domains_whitelist settings. pub fn is_signup_allowed(&self, email: &str) -> bool { - if !self.signups_domains_whitelist().is_empty() { - // The whitelist setting overrides the signups_allowed setting. - self.is_email_domain_allowed(email) - } else { - self.signups_allowed() - } + !self.is_signup_disabled() && self.is_email_domain_allowed(email) } /// Tests whether the specified user is allowed to create an organization. @@ -1467,6 +1573,22 @@ impl Config { } } } + + pub fn sso_issuer_url(&self) -> Result { + internal_sso_issuer_url(&self.sso_authority()) + } + + pub fn sso_redirect_url(&self) -> Result { + internal_sso_redirect_url(&self.sso_callback_path()) + } + + pub fn sso_scopes_vec(&self) -> Vec { + self.sso_scopes().split_whitespace().map(str::to_string).collect() + } + + pub fn sso_authorize_extra_params_vec(&self) -> Result, Error> { + internal_sso_authorize_extra_params_vec(&self.sso_authorize_extra_params()) + } } use handlebars::{ @@ -1532,6 +1654,7 @@ where reg!("email/send_org_invite", ".html"); reg!("email/send_single_org_removed_from_org", ".html"); reg!("email/smtp_test", ".html"); + reg!("email/sso_change_email", ".html"); reg!("email/twofactor_email", ".html"); reg!("email/verify_email", ".html"); reg!("email/welcome_must_verify", ".html"); @@ -1630,3 +1753,54 @@ handlebars::handlebars_helper!(webver: | web_vault_version: String | handlebars::handlebars_helper!(vwver: | vw_version: String | semver::VersionReq::parse(&vw_version).expect("Invalid Vaultwarden version compare string").matches(&VW_VERSION) ); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_param_list() { + let config = "key1=value&key2=value2&".to_string(); + let parsed = parse_param_list(config, '&', '='); + + assert_eq!( + parsed.unwrap(), + vec![("key1".to_string(), "value".to_string()), ("key2".to_string(), "value2".to_string())] + ); + } + + #[test] + fn test_parse_param_list_lines() { + let config = r#" + key1=value + key2=value2 + "# + .to_string(); + let parsed = parse_param_list(config, '&', '='); + + assert_eq!( + parsed.unwrap(), + vec![("key1".to_string(), "value".to_string()), ("key2".to_string(), "value2".to_string())] + ); + } + + #[test] + fn test_parse_param_list_mixed() { + let config = r#"key1=value&key2=value2& + &key3=value3&& + &key4=value4 + "# + .to_string(); + let parsed = parse_param_list(config, '&', '='); + + assert_eq!( + parsed.unwrap(), + vec![ + ("key1".to_string(), "value".to_string()), + ("key2".to_string(), "value2".to_string()), + ("key3".to_string(), "value3".to_string()), + ("key4".to_string(), "value4".to_string()), + ] + ); + } +} diff --git a/src/db/models/device.rs b/src/db/models/device.rs index 7cd23d5b..91cc1e18 100644 --- a/src/db/models/device.rs +++ b/src/db/models/device.rs @@ -1,4 +1,6 @@ use chrono::{NaiveDateTime, Utc}; + +use data_encoding::{BASE64, BASE64URL}; use derive_more::{Display, From}; use serde_json::Value; @@ -6,7 +8,6 @@ use super::{AuthRequest, UserId}; use crate::{ crypto, util::{format_date, get_uuid}, - CONFIG, }; use macros::{IdFromParam, UuidFromParam}; @@ -34,25 +35,6 @@ db_object! { /// Local methods impl Device { - pub fn new(uuid: DeviceId, user_uuid: UserId, name: String, atype: i32) -> Self { - let now = Utc::now().naive_utc(); - - Self { - uuid, - created_at: now, - updated_at: now, - - user_uuid, - name, - atype, - - push_uuid: Some(PushId(get_uuid())), - push_token: None, - refresh_token: String::new(), - twofactor_remember: None, - } - } - pub fn to_json(&self) -> Value { json!({ "id": self.uuid, @@ -66,7 +48,6 @@ impl Device { } pub fn refresh_twofactor_remember(&mut self) -> String { - use data_encoding::BASE64; let twofactor_remember = crypto::encode_random_bytes::<180>(BASE64); self.twofactor_remember = Some(twofactor_remember.clone()); @@ -77,71 +58,9 @@ impl Device { self.twofactor_remember = None; } - pub fn refresh_tokens( - &mut self, - user: &super::User, - scope: Vec, - client_id: Option, - ) -> (String, i64) { - // If there is no refresh token, we create one - if self.refresh_token.is_empty() { - use data_encoding::BASE64URL; - self.refresh_token = crypto::encode_random_bytes::<64>(BASE64URL); - } - - // Update the expiration of the device and the last update date - let time_now = Utc::now(); - self.updated_at = time_now.naive_utc(); - - // Generate a random push_uuid so if it doesn't already have one - if self.push_uuid.is_none() { - self.push_uuid = Some(PushId(get_uuid())); - } - - // --- - // Disabled these keys to be added to the JWT since they could cause the JWT to get too large - // Also These key/value pairs are not used anywhere by either Vaultwarden or Bitwarden Clients - // Because these might get used in the future, and they are added by the Bitwarden Server, lets keep it, but then commented out - // --- - // fn arg: members: Vec, - // --- - // let orgowner: Vec<_> = members.iter().filter(|m| m.atype == 0).map(|o| o.org_uuid.clone()).collect(); - // let orgadmin: Vec<_> = members.iter().filter(|m| m.atype == 1).map(|o| o.org_uuid.clone()).collect(); - // let orguser: Vec<_> = members.iter().filter(|m| m.atype == 2).map(|o| o.org_uuid.clone()).collect(); - // let orgmanager: Vec<_> = members.iter().filter(|m| m.atype == 3).map(|o| o.org_uuid.clone()).collect(); - - // Create the JWT claims struct, to send to the client - use crate::auth::{encode_jwt, LoginJwtClaims, DEFAULT_VALIDITY, JWT_LOGIN_ISSUER}; - let claims = LoginJwtClaims { - nbf: time_now.timestamp(), - exp: (time_now + *DEFAULT_VALIDITY).timestamp(), - iss: JWT_LOGIN_ISSUER.to_string(), - sub: user.uuid.clone(), - - premium: true, - name: user.name.clone(), - email: user.email.clone(), - email_verified: !CONFIG.mail_enabled() || user.verified_at.is_some(), - - // --- - // Disabled these keys to be added to the JWT since they could cause the JWT to get too large - // Also These key/value pairs are not used anywhere by either Vaultwarden or Bitwarden Clients - // Because these might get used in the future, and they are added by the Bitwarden Server, lets keep it, but then commented out - // See: https://github.com/dani-garcia/vaultwarden/issues/4156 - // --- - // orgowner, - // orgadmin, - // orguser, - // orgmanager, - sstamp: user.security_stamp.clone(), - device: self.uuid.clone(), - devicetype: DeviceType::from_i32(self.atype).to_string(), - client_id: client_id.unwrap_or("undefined".to_string()), - scope, - amr: vec!["Application".into()], - }; - - (encode_jwt(&claims), DEFAULT_VALIDITY.num_seconds()) + // This rely on the fact we only update the device after a successful login + pub fn is_new(&self) -> bool { + self.created_at == self.updated_at } pub fn is_push_device(&self) -> bool { @@ -187,14 +106,39 @@ impl DeviceWithAuthRequest { } use crate::db::DbConn; -use crate::api::EmptyResult; +use crate::api::{ApiResult, EmptyResult}; use crate::error::MapResult; /// Database methods impl Device { - pub async fn save(&mut self, conn: &mut DbConn) -> EmptyResult { - self.updated_at = Utc::now().naive_utc(); + pub async fn new( + uuid: DeviceId, + user_uuid: UserId, + name: String, + atype: i32, + conn: &mut DbConn, + ) -> ApiResult { + let now = Utc::now().naive_utc(); + + let device = Self { + uuid, + created_at: now, + updated_at: now, + + user_uuid, + name, + atype, + + push_uuid: Some(PushId(get_uuid())), + push_token: None, + refresh_token: crypto::encode_random_bytes::<64>(BASE64URL), + twofactor_remember: None, + }; + + device.inner_save(conn).await.map(|()| device) + } + async fn inner_save(&self, conn: &mut DbConn) -> EmptyResult { db_run! { conn: sqlite, mysql { crate::util::retry( @@ -212,6 +156,12 @@ impl Device { } } + // Should only be called after user has passed authentication + pub async fn save(&mut self, conn: &mut DbConn) -> EmptyResult { + self.updated_at = Utc::now().naive_utc(); + self.inner_save(conn).await + } + pub async fn delete_all_by_user(user_uuid: &UserId, conn: &mut DbConn) -> EmptyResult { db_run! { conn: { diesel::delete(devices::table.filter(devices::user_uuid.eq(user_uuid))) @@ -403,6 +353,10 @@ impl DeviceType { _ => DeviceType::UnknownBrowser, } } + + pub fn is_mobile(value: &i32) -> bool { + *value == DeviceType::Android as i32 || *value == DeviceType::Ios as i32 + } } #[derive( diff --git a/src/db/models/event.rs b/src/db/models/event.rs index 76d12c02..3eb81837 100644 --- a/src/db/models/event.rs +++ b/src/db/models/event.rs @@ -89,7 +89,7 @@ pub enum EventType { OrganizationUserUpdated = 1502, OrganizationUserRemoved = 1503, // Organization user data was deleted OrganizationUserUpdatedGroups = 1504, - // OrganizationUserUnlinkedSso = 1505, // Not supported + OrganizationUserUnlinkedSso = 1505, // Not supported OrganizationUserResetPasswordEnroll = 1506, OrganizationUserResetPasswordWithdraw = 1507, OrganizationUserAdminResetPassword = 1508, diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index 6b569a56..a9406ed0 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -11,6 +11,7 @@ mod group; mod org_policy; mod organization; mod send; +mod sso_nonce; mod two_factor; mod two_factor_duo_context; mod two_factor_incomplete; @@ -35,7 +36,8 @@ pub use self::send::{ id::{SendFileId, SendId}, Send, SendType, }; +pub use self::sso_nonce::SsoNonce; pub use self::two_factor::{TwoFactor, TwoFactorType}; pub use self::two_factor_duo_context::TwoFactorDuoContext; pub use self::two_factor_incomplete::TwoFactorIncomplete; -pub use self::user::{Invitation, User, UserId, UserKdfType, UserStampException}; +pub use self::user::{Invitation, SsoUser, User, UserId, UserKdfType, UserStampException}; diff --git a/src/db/models/org_policy.rs b/src/db/models/org_policy.rs index f1280563..aac145cb 100644 --- a/src/db/models/org_policy.rs +++ b/src/db/models/org_policy.rs @@ -67,12 +67,12 @@ pub enum OrgPolicyErr { /// Local methods impl OrgPolicy { - pub fn new(org_uuid: OrganizationId, atype: OrgPolicyType, data: String) -> Self { + pub fn new(org_uuid: OrganizationId, atype: OrgPolicyType, enabled: bool, data: String) -> Self { Self { uuid: OrgPolicyId(crate::util::get_uuid()), org_uuid, atype: atype as i32, - enabled: false, + enabled, data, } } diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index f51a9725..77cf91c0 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -36,6 +36,8 @@ db_object! { pub user_uuid: UserId, pub org_uuid: OrganizationId, + pub invited_by_email: Option, + pub access_all: bool, pub akey: String, pub status: i32, @@ -235,12 +237,13 @@ impl Organization { const ACTIVATE_REVOKE_DIFF: i32 = 128; impl Membership { - pub fn new(user_uuid: UserId, org_uuid: OrganizationId) -> Self { + pub fn new(user_uuid: UserId, org_uuid: OrganizationId, invited_by_email: Option) -> Self { Self { uuid: MembershipId(crate::util::get_uuid()), user_uuid, org_uuid, + invited_by_email, access_all: false, akey: String::new(), @@ -389,11 +392,53 @@ impl Organization { }} } + pub async fn find_by_name(name: &str, conn: &mut DbConn) -> Option { + db_run! { conn: { + organizations::table + .filter(organizations::name.eq(name)) + .first::(conn) + .ok().from_db() + }} + } + pub async fn get_all(conn: &mut DbConn) -> Vec { db_run! { conn: { organizations::table.load::(conn).expect("Error loading organizations").from_db() }} } + + pub async fn find_main_org_user_email(user_email: &str, conn: &mut DbConn) -> Option { + let lower_mail = user_email.to_lowercase(); + + db_run! { conn: { + organizations::table + .inner_join(users_organizations::table.on(users_organizations::org_uuid.eq(organizations::uuid))) + .inner_join(users::table.on(users::uuid.eq(users_organizations::user_uuid))) + .filter(users::email.eq(lower_mail)) + .filter(users_organizations::status.ne(MembershipStatus::Revoked as i32)) + .order(users_organizations::atype.asc()) + .select(organizations::all_columns) + .first::(conn) + .ok().from_db() + }} + } + + pub async fn find_org_user_email(user_email: &str, conn: &mut DbConn) -> Vec { + let lower_mail = user_email.to_lowercase(); + + db_run! { conn: { + organizations::table + .inner_join(users_organizations::table.on(users_organizations::org_uuid.eq(organizations::uuid))) + .inner_join(users::table.on(users::uuid.eq(users_organizations::user_uuid))) + .filter(users::email.eq(lower_mail)) + .filter(users_organizations::status.ne(MembershipStatus::Revoked as i32)) + .order(users_organizations::atype.asc()) + .select(organizations::all_columns) + .load::(conn) + .expect("Error loading user orgs") + .from_db() + }} + } } impl Membership { @@ -827,6 +872,19 @@ impl Membership { }} } + // Should be used only when email are disabled. + // In Organizations::send_invite status is set to Accepted only if the user has a password. + pub async fn accept_user_invitations(user_uuid: &UserId, conn: &mut DbConn) -> EmptyResult { + db_run! { conn: { + diesel::update(users_organizations::table) + .filter(users_organizations::user_uuid.eq(user_uuid)) + .filter(users_organizations::status.eq(MembershipStatus::Invited as i32)) + .set(users_organizations::status.eq(MembershipStatus::Accepted as i32)) + .execute(conn) + .map_res("Error confirming invitations") + }} + } + pub async fn find_any_state_by_user(user_uuid: &UserId, conn: &mut DbConn) -> Vec { db_run! { conn: { users_organizations::table @@ -1103,6 +1161,17 @@ impl Membership { .first::(conn).ok().from_db() }} } + + pub async fn find_main_user_org(user_uuid: &str, conn: &mut DbConn) -> Option { + db_run! { conn: { + users_organizations::table + .filter(users_organizations::user_uuid.eq(user_uuid)) + .filter(users_organizations::status.ne(MembershipStatus::Revoked as i32)) + .order(users_organizations::atype.asc()) + .first::(conn) + .ok().from_db() + }} + } } impl OrganizationApiKey { diff --git a/src/db/models/sso_nonce.rs b/src/db/models/sso_nonce.rs new file mode 100644 index 00000000..2246a437 --- /dev/null +++ b/src/db/models/sso_nonce.rs @@ -0,0 +1,89 @@ +use chrono::{NaiveDateTime, Utc}; + +use crate::api::EmptyResult; +use crate::db::{DbConn, DbPool}; +use crate::error::MapResult; +use crate::sso::{OIDCState, NONCE_EXPIRATION}; + +db_object! { + #[derive(Identifiable, Queryable, Insertable)] + #[diesel(table_name = sso_nonce)] + #[diesel(primary_key(state))] + pub struct SsoNonce { + pub state: OIDCState, + pub nonce: String, + pub verifier: Option, + pub redirect_uri: String, + pub created_at: NaiveDateTime, + } +} + +/// Local methods +impl SsoNonce { + pub fn new(state: OIDCState, nonce: String, verifier: Option, redirect_uri: String) -> Self { + let now = Utc::now().naive_utc(); + + SsoNonce { + state, + nonce, + verifier, + redirect_uri, + created_at: now, + } + } +} + +/// Database methods +impl SsoNonce { + pub async fn save(&self, conn: &mut DbConn) -> EmptyResult { + db_run! { conn: + sqlite, mysql { + diesel::replace_into(sso_nonce::table) + .values(SsoNonceDb::to_db(self)) + .execute(conn) + .map_res("Error saving SSO nonce") + } + postgresql { + let value = SsoNonceDb::to_db(self); + diesel::insert_into(sso_nonce::table) + .values(&value) + .execute(conn) + .map_res("Error saving SSO nonce") + } + } + } + + pub async fn delete(state: &OIDCState, conn: &mut DbConn) -> EmptyResult { + db_run! { conn: { + diesel::delete(sso_nonce::table.filter(sso_nonce::state.eq(state))) + .execute(conn) + .map_res("Error deleting SSO nonce") + }} + } + + pub async fn find(state: &OIDCState, conn: &DbConn) -> Option { + let oldest = Utc::now().naive_utc() - *NONCE_EXPIRATION; + db_run! { conn: { + sso_nonce::table + .filter(sso_nonce::state.eq(state)) + .filter(sso_nonce::created_at.ge(oldest)) + .first::(conn) + .ok() + .from_db() + }} + } + + pub async fn delete_expired(pool: DbPool) -> EmptyResult { + debug!("Purging expired sso_nonce"); + if let Ok(conn) = pool.get().await { + let oldest = Utc::now().naive_utc() - *NONCE_EXPIRATION; + db_run! { conn: { + diesel::delete(sso_nonce::table.filter(sso_nonce::created_at.lt(oldest))) + .execute(conn) + .map_res("Error deleting expired SSO nonce") + }} + } else { + err!("Failed to get DB connection while purging expired sso_nonce") + } + } +} diff --git a/src/db/models/user.rs b/src/db/models/user.rs index b5b78ad0..3a3b5157 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -8,15 +8,17 @@ use super::{ use crate::{ api::EmptyResult, crypto, + db::models::DeviceId, db::DbConn, error::MapResult, + sso::OIDCIdentifier, util::{format_date, get_uuid, retry}, CONFIG, }; use macros::UuidFromParam; db_object! { - #[derive(Identifiable, Queryable, Insertable, AsChangeset)] + #[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)] #[diesel(table_name = users)] #[diesel(treat_none_as_null = true)] #[diesel(primary_key(uuid))] @@ -71,6 +73,14 @@ db_object! { pub struct Invitation { pub email: String, } + + #[derive(Identifiable, Queryable, Insertable, Selectable)] + #[diesel(table_name = sso_users)] + #[diesel(primary_key(user_uuid))] + pub struct SsoUser { + pub user_uuid: UserId, + pub identifier: OIDCIdentifier, + } } pub enum UserKdfType { @@ -96,7 +106,7 @@ impl User { pub const CLIENT_KDF_TYPE_DEFAULT: i32 = UserKdfType::Pbkdf2 as i32; pub const CLIENT_KDF_ITER_DEFAULT: i32 = 600_000; - pub fn new(email: String) -> Self { + pub fn new(email: String, name: Option) -> Self { let now = Utc::now().naive_utc(); let email = email.to_lowercase(); @@ -108,7 +118,7 @@ impl User { verified_at: None, last_verifying_at: None, login_verify_count: 0, - name: email.clone(), + name: name.unwrap_or(email.clone()), email, akey: String::new(), email_new: None, @@ -384,9 +394,28 @@ impl User { }} } - pub async fn get_all(conn: &mut DbConn) -> Vec { + pub async fn find_by_device_id(device_uuid: &DeviceId, conn: &mut DbConn) -> Option { + db_run! { conn: { + users::table + .inner_join(devices::table.on(devices::user_uuid.eq(users::uuid))) + .filter(devices::uuid.eq(device_uuid)) + .select(users::all_columns) + .first::(conn) + .ok() + .from_db() + }} + } + + pub async fn get_all(conn: &mut DbConn) -> Vec<(User, Option)> { db_run! {conn: { - users::table.load::(conn).expect("Error loading users").from_db() + users::table + .left_join(sso_users::table) + .select(<(UserDb, Option)>::as_select()) + .load(conn) + .expect("Error loading groups for user") + .into_iter() + .map(|(user, sso_user)| { (user.from_db(), sso_user.from_db()) }) + .collect() }} } @@ -477,3 +506,57 @@ impl Invitation { #[deref(forward)] #[from(forward)] pub struct UserId(String); + +impl SsoUser { + pub async fn save(&self, conn: &mut DbConn) -> EmptyResult { + db_run! { conn: + sqlite, mysql { + diesel::replace_into(sso_users::table) + .values(SsoUserDb::to_db(self)) + .execute(conn) + .map_res("Error saving SSO user") + } + postgresql { + let value = SsoUserDb::to_db(self); + diesel::insert_into(sso_users::table) + .values(&value) + .execute(conn) + .map_res("Error saving SSO user") + } + } + } + + pub async fn find_by_identifier(identifier: &str, conn: &DbConn) -> Option<(User, SsoUser)> { + db_run! {conn: { + users::table + .inner_join(sso_users::table) + .select(<(UserDb, SsoUserDb)>::as_select()) + .filter(sso_users::identifier.eq(identifier)) + .first::<(UserDb, SsoUserDb)>(conn) + .ok() + .map(|(user, sso_user)| { (user.from_db(), sso_user.from_db()) }) + }} + } + + pub async fn find_by_mail(mail: &str, conn: &DbConn) -> Option<(User, Option)> { + let lower_mail = mail.to_lowercase(); + + db_run! {conn: { + users::table + .left_join(sso_users::table) + .select(<(UserDb, Option)>::as_select()) + .filter(users::email.eq(lower_mail)) + .first::<(UserDb, Option)>(conn) + .ok() + .map(|(user, sso_user)| { (user.from_db(), sso_user.from_db()) }) + }} + } + + pub async fn delete(user_uuid: &UserId, conn: &mut DbConn) -> EmptyResult { + db_run! {conn: { + diesel::delete(sso_users::table.filter(sso_users::user_uuid.eq(user_uuid))) + .execute(conn) + .map_res("Error deleting sso user") + }} + } +} diff --git a/src/db/schemas/mysql/schema.rs b/src/db/schemas/mysql/schema.rs index 573e4503..001e43b4 100644 --- a/src/db/schemas/mysql/schema.rs +++ b/src/db/schemas/mysql/schema.rs @@ -235,6 +235,7 @@ table! { uuid -> Text, user_uuid -> Text, org_uuid -> Text, + invited_by_email -> Nullable, access_all -> Bool, akey -> Text, status -> Integer, @@ -254,6 +255,23 @@ table! { } } +table! { + sso_nonce (state) { + state -> Text, + nonce -> Text, + verifier -> Nullable, + redirect_uri -> Text, + created_at -> Timestamp, + } +} + +table! { + sso_users (user_uuid) { + user_uuid -> Text, + identifier -> Text, + } +} + table! { emergency_access (uuid) { uuid -> Text, @@ -348,6 +366,7 @@ joinable!(collections_groups -> collections (collections_uuid)); joinable!(collections_groups -> groups (groups_uuid)); joinable!(event -> users_organizations (uuid)); joinable!(auth_requests -> users (user_uuid)); +joinable!(sso_users -> users (user_uuid)); allow_tables_to_appear_in_same_query!( attachments, @@ -361,6 +380,7 @@ allow_tables_to_appear_in_same_query!( org_policies, organizations, sends, + sso_users, twofactor, users, users_collections, diff --git a/src/db/schemas/postgresql/schema.rs b/src/db/schemas/postgresql/schema.rs index a3707adf..a0f31f1e 100644 --- a/src/db/schemas/postgresql/schema.rs +++ b/src/db/schemas/postgresql/schema.rs @@ -235,6 +235,7 @@ table! { uuid -> Text, user_uuid -> Text, org_uuid -> Text, + invited_by_email -> Nullable, access_all -> Bool, akey -> Text, status -> Integer, @@ -254,6 +255,23 @@ table! { } } +table! { + sso_nonce (state) { + state -> Text, + nonce -> Text, + verifier -> Nullable, + redirect_uri -> Text, + created_at -> Timestamp, + } +} + +table! { + sso_users (user_uuid) { + user_uuid -> Text, + identifier -> Text, + } +} + table! { emergency_access (uuid) { uuid -> Text, @@ -348,6 +366,7 @@ joinable!(collections_groups -> collections (collections_uuid)); joinable!(collections_groups -> groups (groups_uuid)); joinable!(event -> users_organizations (uuid)); joinable!(auth_requests -> users (user_uuid)); +joinable!(sso_users -> users (user_uuid)); allow_tables_to_appear_in_same_query!( attachments, @@ -361,6 +380,7 @@ allow_tables_to_appear_in_same_query!( org_policies, organizations, sends, + sso_users, twofactor, users, users_collections, diff --git a/src/db/schemas/sqlite/schema.rs b/src/db/schemas/sqlite/schema.rs index a3707adf..a0f31f1e 100644 --- a/src/db/schemas/sqlite/schema.rs +++ b/src/db/schemas/sqlite/schema.rs @@ -235,6 +235,7 @@ table! { uuid -> Text, user_uuid -> Text, org_uuid -> Text, + invited_by_email -> Nullable, access_all -> Bool, akey -> Text, status -> Integer, @@ -254,6 +255,23 @@ table! { } } +table! { + sso_nonce (state) { + state -> Text, + nonce -> Text, + verifier -> Nullable, + redirect_uri -> Text, + created_at -> Timestamp, + } +} + +table! { + sso_users (user_uuid) { + user_uuid -> Text, + identifier -> Text, + } +} + table! { emergency_access (uuid) { uuid -> Text, @@ -348,6 +366,7 @@ joinable!(collections_groups -> collections (collections_uuid)); joinable!(collections_groups -> groups (groups_uuid)); joinable!(event -> users_organizations (uuid)); joinable!(auth_requests -> users (user_uuid)); +joinable!(sso_users -> users (user_uuid)); allow_tables_to_appear_in_same_query!( attachments, @@ -361,6 +380,7 @@ allow_tables_to_appear_in_same_query!( org_policies, organizations, sends, + sso_users, twofactor, users, users_collections, diff --git a/src/error.rs b/src/error.rs index 04de98a4..beaf4780 100644 --- a/src/error.rs +++ b/src/error.rs @@ -159,6 +159,10 @@ impl Error { pub fn get_event(&self) -> &Option { &self.event } + + pub fn message(&self) -> &str { + &self.message + } } pub trait MapResult { @@ -278,9 +282,15 @@ macro_rules! err_silent { ($msg:expr) => {{ return Err($crate::error::Error::new($msg, $msg)); }}; + ($msg:expr, ErrorEvent $err_event:tt) => {{ + return Err($crate::error::Error::new($msg, $msg).with_event($crate::error::ErrorEvent $err_event)); + }}; ($usr_msg:expr, $log_value:expr) => {{ return Err($crate::error::Error::new($usr_msg, $log_value)); }}; + ($usr_msg:expr, $log_value:expr, ErrorEvent $err_event:tt) => {{ + return Err($crate::error::Error::new($usr_msg, $log_value).with_event($crate::error::ErrorEvent $err_event)); + }}; } #[macro_export] diff --git a/src/mail.rs b/src/mail.rs index b1f37886..ca5b7eb5 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -301,7 +301,11 @@ pub async fn send_invite( .append_pair("organizationId", &org_id) .append_pair("organizationUserId", &member_id) .append_pair("token", &invite_token); - if user.private_key.is_some() { + + if CONFIG.sso_enabled() && CONFIG.sso_only() { + query_params.append_pair("orgUserHasExistingUser", "false"); + query_params.append_pair("orgSsoIdentifier", org_name); + } else if user.private_key.is_some() { query_params.append_pair("orgUserHasExistingUser", "true"); } } @@ -584,6 +588,18 @@ pub async fn send_change_email_existing(address: &str, acting_address: &str) -> send_email(address, &subject, body_html, body_text).await } +pub async fn send_sso_change_email(address: &str) -> EmptyResult { + let (subject, body_html, body_text) = get_text( + "email/sso_change_email", + json!({ + "url": format!("{}/#/settings/account", CONFIG.domain()), + "img_src": CONFIG._smtp_img_src(), + }), + )?; + + send_email(address, &subject, body_html, body_text).await +} + pub async fn send_test(address: &str) -> EmptyResult { let (subject, body_html, body_text) = get_text( "email/smtp_test", diff --git a/src/main.rs b/src/main.rs index fc104997..8fbd3453 100644 --- a/src/main.rs +++ b/src/main.rs @@ -56,6 +56,8 @@ mod db; mod http_client; mod mail; mod ratelimit; +mod sso; +mod sso_client; mod util; use crate::api::core::two_factor::duo_oidc::purge_duo_contexts; @@ -711,6 +713,13 @@ fn schedule_jobs(pool: db::DbPool) { })); } + // Purge sso nonce from incomplete flow (default to daily at 00h20). + if !CONFIG.purge_incomplete_sso_nonce().is_empty() { + sched.add(Job::new(CONFIG.purge_incomplete_sso_nonce().parse().unwrap(), || { + runtime.spawn(db::models::SsoNonce::delete_expired(pool.clone())); + })); + } + // Periodically check for jobs to run. We probably won't need any // jobs that run more often than once a minute, so a default poll // interval of 30 seconds should be sufficient. Users who want to diff --git a/src/sso.rs b/src/sso.rs new file mode 100644 index 00000000..4f7ed86a --- /dev/null +++ b/src/sso.rs @@ -0,0 +1,462 @@ +use chrono::Utc; +use derive_more::{AsRef, Deref, Display, From}; +use regex::Regex; +use std::time::Duration; +use url::Url; + +use mini_moka::sync::Cache; +use once_cell::sync::Lazy; + +use crate::{ + api::ApiResult, + auth, + auth::{AuthMethod, AuthTokens, TokenWrapper, BW_EXPIRATION, DEFAULT_REFRESH_VALIDITY}, + db::{ + models::{Device, SsoNonce, User}, + DbConn, + }, + sso_client::Client, + CONFIG, +}; + +pub static FAKE_IDENTIFIER: &str = "Vaultwarden"; + +static AC_CACHE: Lazy> = + Lazy::new(|| Cache::builder().max_capacity(1000).time_to_live(Duration::from_secs(10 * 60)).build()); + +static SSO_JWT_ISSUER: Lazy = Lazy::new(|| format!("{}|sso", CONFIG.domain_origin())); + +pub static NONCE_EXPIRATION: Lazy = Lazy::new(|| chrono::TimeDelta::try_minutes(10).unwrap()); + +#[derive( + Clone, + Debug, + Default, + DieselNewType, + FromForm, + PartialEq, + Eq, + Hash, + Serialize, + Deserialize, + AsRef, + Deref, + Display, + From, +)] +#[deref(forward)] +#[from(forward)] +pub struct OIDCCode(String); + +#[derive( + Clone, + Debug, + Default, + DieselNewType, + FromForm, + PartialEq, + Eq, + Hash, + Serialize, + Deserialize, + AsRef, + Deref, + Display, + From, +)] +#[deref(forward)] +#[from(forward)] +pub struct OIDCState(String); + +#[derive(Debug, Serialize, Deserialize)] +struct SsoTokenJwtClaims { + // Not before + pub nbf: i64, + // Expiration time + pub exp: i64, + // Issuer + pub iss: String, + // Subject + pub sub: String, +} + +pub fn encode_ssotoken_claims() -> String { + let time_now = Utc::now(); + let claims = SsoTokenJwtClaims { + nbf: time_now.timestamp(), + exp: (time_now + chrono::TimeDelta::try_minutes(2).unwrap()).timestamp(), + iss: SSO_JWT_ISSUER.to_string(), + sub: "vaultwarden".to_string(), + }; + + auth::encode_jwt(&claims) +} + +#[derive(Debug, Serialize, Deserialize)] +pub enum OIDCCodeWrapper { + Ok { + state: OIDCState, + code: OIDCCode, + }, + Error { + state: OIDCState, + error: String, + error_description: Option, + }, +} + +#[derive(Debug, Serialize, Deserialize)] +struct OIDCCodeClaims { + // Expiration time + pub exp: i64, + // Issuer + pub iss: String, + + pub code: OIDCCodeWrapper, +} + +pub fn encode_code_claims(code: OIDCCodeWrapper) -> String { + let time_now = Utc::now(); + let claims = OIDCCodeClaims { + exp: (time_now + chrono::TimeDelta::try_minutes(5).unwrap()).timestamp(), + iss: SSO_JWT_ISSUER.to_string(), + code, + }; + + auth::encode_jwt(&claims) +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct BasicTokenClaims { + iat: Option, + nbf: Option, + exp: i64, +} + +impl BasicTokenClaims { + fn nbf(&self) -> i64 { + self.nbf.or(self.iat).unwrap_or_else(|| Utc::now().timestamp()) + } +} + +fn decode_token_claims(token_name: &str, token: &str) -> ApiResult { + let mut validation = jsonwebtoken::Validation::default(); + validation.set_issuer(&[CONFIG.sso_authority()]); + validation.insecure_disable_signature_validation(); + validation.validate_aud = false; + + match jsonwebtoken::decode(token, &jsonwebtoken::DecodingKey::from_secret(&[]), &validation) { + Ok(btc) => Ok(btc.claims), + Err(err) => err_silent!(format!("Failed to decode basic token claims from {token_name}: {err}")), + } +} + +pub fn deocde_state(base64_state: String) -> ApiResult { + let state = match data_encoding::BASE64.decode(base64_state.as_bytes()) { + Ok(vec) => match String::from_utf8(vec) { + Ok(valid) => OIDCState(valid), + Err(_) => err!(format!("Invalid utf8 chars in {base64_state} after base64 decoding")), + }, + Err(_) => err!(format!("Failed to decode {base64_state} using base64")), + }; + + Ok(state) +} + +// The `nonce` allow to protect against replay attacks +// redirect_uri from: https://github.com/bitwarden/server/blob/main/src/Identity/IdentityServer/ApiClient.cs +pub async fn authorize_url( + state: OIDCState, + client_id: &str, + raw_redirect_uri: &str, + mut conn: DbConn, +) -> ApiResult { + let redirect_uri = match client_id { + "web" | "browser" => format!("{}/sso-connector.html", CONFIG.domain()), + "desktop" | "mobile" => "bitwarden://sso-callback".to_string(), + "cli" => { + let port_regex = Regex::new(r"^http://localhost:([0-9]{4})$").unwrap(); + match port_regex.captures(raw_redirect_uri).and_then(|captures| captures.get(1).map(|c| c.as_str())) { + Some(port) => format!("http://localhost:{port}"), + None => err!("Failed to extract port number"), + } + } + _ => err!(format!("Unsupported client {client_id}")), + }; + + let (auth_url, nonce) = Client::authorize_url(state, redirect_uri).await?; + nonce.save(&mut conn).await?; + Ok(auth_url) +} + +#[derive( + Clone, + Debug, + Default, + DieselNewType, + FromForm, + PartialEq, + Eq, + Hash, + Serialize, + Deserialize, + AsRef, + Deref, + Display, + From, +)] +#[deref(forward)] +#[from(forward)] +pub struct OIDCIdentifier(String); + +impl OIDCIdentifier { + fn new(issuer: &str, subject: &str) -> Self { + OIDCIdentifier(format!("{issuer}/{subject}")) + } +} + +#[derive(Clone, Debug)] +pub struct AuthenticatedUser { + pub refresh_token: Option, + pub access_token: String, + pub expires_in: Option, + pub identifier: OIDCIdentifier, + pub email: String, + pub email_verified: Option, + pub user_name: Option, +} + +#[derive(Clone, Debug)] +pub struct UserInformation { + pub state: OIDCState, + pub identifier: OIDCIdentifier, + pub email: String, + pub email_verified: Option, + pub user_name: Option, +} + +async fn decode_code_claims(code: &str, conn: &mut DbConn) -> ApiResult<(OIDCCode, OIDCState)> { + match auth::decode_jwt::(code, SSO_JWT_ISSUER.to_string()) { + Ok(code_claims) => match code_claims.code { + OIDCCodeWrapper::Ok { + state, + code, + } => Ok((code, state)), + OIDCCodeWrapper::Error { + state, + error, + error_description, + } => { + if let Err(err) = SsoNonce::delete(&state, conn).await { + error!("Failed to delete database sso_nonce using {state}: {err}") + } + err!(format!( + "SSO authorization failed: {error}, {}", + error_description.as_ref().unwrap_or(&String::new()) + )) + } + }, + Err(err) => err!(format!("Failed to decode code wrapper: {err}")), + } +} + +// During the 2FA flow we will +// - retrieve the user information and then only discover he needs 2FA. +// - second time we will rely on the `AC_CACHE` since the `code` has already been exchanged. +// The `nonce` will ensure that the user is authorized only once. +// We return only the `UserInformation` to force calling `redeem` to obtain the `refresh_token`. +pub async fn exchange_code(wrapped_code: &str, conn: &mut DbConn) -> ApiResult { + use openidconnect::OAuth2TokenResponse; + + let (code, state) = decode_code_claims(wrapped_code, conn).await?; + + if let Some(authenticated_user) = AC_CACHE.get(&state) { + return Ok(UserInformation { + state, + identifier: authenticated_user.identifier, + email: authenticated_user.email, + email_verified: authenticated_user.email_verified, + user_name: authenticated_user.user_name, + }); + } + + let nonce = match SsoNonce::find(&state, conn).await { + None => err!(format!("Invalid state cannot retrieve nonce")), + Some(nonce) => nonce, + }; + + let client = Client::cached().await?; + let (token_response, id_claims) = client.exchange_code(code, nonce).await?; + + let user_info = client.user_info(token_response.access_token().to_owned()).await?; + + let email = match id_claims.email().or(user_info.email()) { + None => err!("Neither id token nor userinfo contained an email"), + Some(e) => e.to_string().to_lowercase(), + }; + + let email_verified = id_claims.email_verified().or(user_info.email_verified()); + + let user_name = id_claims.preferred_username().map(|un| un.to_string()); + + let refresh_token = token_response.refresh_token().map(|t| t.secret()); + if refresh_token.is_none() && CONFIG.sso_scopes_vec().contains(&"offline_access".to_string()) { + error!("Scope offline_access is present but response contain no refresh_token"); + } + + let identifier = OIDCIdentifier::new(id_claims.issuer(), id_claims.subject()); + + let authenticated_user = AuthenticatedUser { + refresh_token: refresh_token.cloned(), + access_token: token_response.access_token().secret().clone(), + expires_in: token_response.expires_in(), + identifier: identifier.clone(), + email: email.clone(), + email_verified, + user_name: user_name.clone(), + }; + + debug!("Authentified user {authenticated_user:?}"); + + AC_CACHE.insert(state.clone(), authenticated_user); + + Ok(UserInformation { + state, + identifier, + email, + email_verified, + user_name, + }) +} + +// User has passed 2FA flow we can delete `nonce` and clear the cache. +pub async fn redeem(state: &OIDCState, conn: &mut DbConn) -> ApiResult { + if let Err(err) = SsoNonce::delete(state, conn).await { + error!("Failed to delete database sso_nonce using {state}: {err}") + } + + if let Some(au) = AC_CACHE.get(state) { + AC_CACHE.invalidate(state); + Ok(au) + } else { + err!("Failed to retrieve user info from sso cache") + } +} + +// We always return a refresh_token (with no refresh_token some secrets are not displayed in the web front). +// If there is no SSO refresh_token, we keep the access_token to be able to call user_info to check for validity +pub fn create_auth_tokens( + device: &Device, + user: &User, + client_id: Option, + refresh_token: Option, + access_token: String, + expires_in: Option, +) -> ApiResult { + if !CONFIG.sso_auth_only_not_session() { + let now = Utc::now(); + + let (ap_nbf, ap_exp) = match (decode_token_claims("access_token", &access_token), expires_in) { + (Ok(ap), _) => (ap.nbf(), ap.exp), + (Err(_), Some(exp)) => (now.timestamp(), (now + exp).timestamp()), + _ => err!("Non jwt access_token and empty expires_in"), + }; + + let access_claims = + auth::LoginJwtClaims::new(device, user, ap_nbf, ap_exp, AuthMethod::Sso.scope_vec(), client_id, now); + + _create_auth_tokens(device, refresh_token, access_claims, access_token) + } else { + Ok(AuthTokens::new(device, user, AuthMethod::Sso, client_id)) + } +} + +fn _create_auth_tokens( + device: &Device, + refresh_token: Option, + access_claims: auth::LoginJwtClaims, + access_token: String, +) -> ApiResult { + let (nbf, exp, token) = if let Some(rt) = refresh_token { + match decode_token_claims("refresh_token", &rt) { + Err(_) => { + let time_now = Utc::now(); + let exp = (time_now + *DEFAULT_REFRESH_VALIDITY).timestamp(); + debug!("Non jwt refresh_token (expiration set to {exp})"); + (time_now.timestamp(), exp, TokenWrapper::Refresh(rt)) + } + Ok(refresh_payload) => { + debug!("Refresh_payload: {refresh_payload:?}"); + (refresh_payload.nbf(), refresh_payload.exp, TokenWrapper::Refresh(rt)) + } + } + } else { + debug!("No refresh_token present"); + (access_claims.nbf, access_claims.exp, TokenWrapper::Access(access_token)) + }; + + let refresh_claims = auth::RefreshJwtClaims { + nbf, + exp, + iss: auth::JWT_LOGIN_ISSUER.to_string(), + sub: AuthMethod::Sso, + device_token: device.refresh_token.clone(), + token: Some(token), + }; + + Ok(AuthTokens { + refresh_claims, + access_claims, + }) +} + +// This endpoint is called in two case +// - the session is close to expiration we will try to extend it +// - the user is going to make an action and we check that the session is still valid +pub async fn exchange_refresh_token( + device: &Device, + user: &User, + client_id: Option, + refresh_claims: auth::RefreshJwtClaims, +) -> ApiResult { + let exp = refresh_claims.exp; + match refresh_claims.token { + Some(TokenWrapper::Refresh(refresh_token)) => { + // Use new refresh_token if returned + let (new_refresh_token, access_token, expires_in) = + Client::exchange_refresh_token(refresh_token.clone()).await?; + + create_auth_tokens( + device, + user, + client_id, + new_refresh_token.or(Some(refresh_token)), + access_token, + expires_in, + ) + } + Some(TokenWrapper::Access(access_token)) => { + let now = Utc::now(); + let exp_limit = (now + *BW_EXPIRATION).timestamp(); + + if exp < exp_limit { + err_silent!("Access token is close to expiration but we have no refresh token") + } + + Client::check_validaty(access_token.clone()).await?; + + let access_claims = auth::LoginJwtClaims::new( + device, + user, + now.timestamp(), + exp, + AuthMethod::Sso.scope_vec(), + client_id, + now, + ); + + _create_auth_tokens(device, None, access_claims, access_token) + } + None => err!("No token present while in SSO"), + } +} diff --git a/src/sso_client.rs b/src/sso_client.rs new file mode 100644 index 00000000..f3aa667c --- /dev/null +++ b/src/sso_client.rs @@ -0,0 +1,264 @@ +use regex::Regex; +use std::borrow::Cow; +use std::time::Duration; +use url::Url; + +use mini_moka::sync::Cache; +use once_cell::sync::Lazy; +use openidconnect::core::*; +use openidconnect::reqwest; +use openidconnect::*; + +use crate::{ + api::{ApiResult, EmptyResult}, + db::models::SsoNonce, + sso::{OIDCCode, OIDCState}, + CONFIG, +}; + +static CLIENT_CACHE_KEY: Lazy = Lazy::new(|| "sso-client".to_string()); +static CLIENT_CACHE: Lazy> = Lazy::new(|| { + Cache::builder().max_capacity(1).time_to_live(Duration::from_secs(CONFIG.sso_client_cache_expiration())).build() +}); + +/// OpenID Connect Core client. +pub type CustomClient = openidconnect::Client< + EmptyAdditionalClaims, + CoreAuthDisplay, + CoreGenderClaim, + CoreJweContentEncryptionAlgorithm, + CoreJsonWebKey, + CoreAuthPrompt, + StandardErrorResponse, + CoreTokenResponse, + CoreTokenIntrospectionResponse, + CoreRevocableToken, + CoreRevocationErrorResponse, + EndpointSet, + EndpointNotSet, + EndpointNotSet, + EndpointNotSet, + EndpointSet, + EndpointSet, +>; + +#[derive(Clone)] +pub struct Client { + pub http_client: reqwest::Client, + pub core_client: CustomClient, +} + +impl Client { + // Call the OpenId discovery endpoint to retrieve configuration + async fn _get_client() -> ApiResult { + let client_id = ClientId::new(CONFIG.sso_client_id()); + let client_secret = ClientSecret::new(CONFIG.sso_client_secret()); + + let issuer_url = CONFIG.sso_issuer_url()?; + + let http_client = match reqwest::ClientBuilder::new().redirect(reqwest::redirect::Policy::none()).build() { + Err(err) => err!(format!("Failed to build http client: {err}")), + Ok(client) => client, + }; + + let provider_metadata = match CoreProviderMetadata::discover_async(issuer_url, &http_client).await { + Err(err) => err!(format!("Failed to discover OpenID provider: {err}")), + Ok(metadata) => metadata, + }; + + let base_client = CoreClient::from_provider_metadata(provider_metadata, client_id, Some(client_secret)); + + let token_uri = match base_client.token_uri() { + Some(uri) => uri.clone(), + None => err!("Failed to discover token_url, cannot proceed"), + }; + + let user_info_url = match base_client.user_info_url() { + Some(url) => url.clone(), + None => err!("Failed to discover user_info url, cannot proceed"), + }; + + let core_client = base_client + .set_redirect_uri(CONFIG.sso_redirect_url()?) + .set_token_uri(token_uri) + .set_user_info_url(user_info_url); + + Ok(Client { + http_client, + core_client, + }) + } + + // Simple cache to prevent recalling the discovery endpoint each time + pub async fn cached() -> ApiResult { + if CONFIG.sso_client_cache_expiration() > 0 { + match CLIENT_CACHE.get(&*CLIENT_CACHE_KEY) { + Some(client) => Ok(client), + None => Self::_get_client().await.inspect(|client| { + debug!("Inserting new client in cache"); + CLIENT_CACHE.insert(CLIENT_CACHE_KEY.clone(), client.clone()); + }), + } + } else { + Self::_get_client().await + } + } + + pub fn invalidate() { + if CONFIG.sso_client_cache_expiration() > 0 { + CLIENT_CACHE.invalidate(&*CLIENT_CACHE_KEY); + } + } + + // The `state` is encoded using base64 to ensure no issue with providers (It contains the Organization identifier). + pub async fn authorize_url(state: OIDCState, redirect_uri: String) -> ApiResult<(Url, SsoNonce)> { + let scopes = CONFIG.sso_scopes_vec().into_iter().map(Scope::new); + let base64_state = data_encoding::BASE64.encode(state.to_string().as_bytes()); + + let client = Self::cached().await?; + let mut auth_req = client + .core_client + .authorize_url( + AuthenticationFlow::::AuthorizationCode, + || CsrfToken::new(base64_state), + Nonce::new_random, + ) + .add_scopes(scopes) + .add_extra_params(CONFIG.sso_authorize_extra_params_vec()?); + + let verifier = if CONFIG.sso_pkce() { + let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); + auth_req = auth_req.set_pkce_challenge(pkce_challenge); + Some(pkce_verifier.into_secret()) + } else { + None + }; + + let (auth_url, _, nonce) = auth_req.url(); + Ok((auth_url, SsoNonce::new(state, nonce.secret().clone(), verifier, redirect_uri))) + } + + pub async fn exchange_code( + &self, + code: OIDCCode, + nonce: SsoNonce, + ) -> ApiResult<( + StandardTokenResponse< + IdTokenFields< + EmptyAdditionalClaims, + EmptyExtraTokenFields, + CoreGenderClaim, + CoreJweContentEncryptionAlgorithm, + CoreJwsSigningAlgorithm, + >, + CoreTokenType, + >, + IdTokenClaims, + )> { + let oidc_code = AuthorizationCode::new(code.to_string()); + + let mut exchange = self.core_client.exchange_code(oidc_code); + + if CONFIG.sso_pkce() { + match nonce.verifier { + None => err!(format!("Missing verifier in the DB nonce table")), + Some(secret) => exchange = exchange.set_pkce_verifier(PkceCodeVerifier::new(secret.clone())), + } + } + + match exchange.request_async(&self.http_client).await { + Err(err) => err!(format!("Failed to contact token endpoint: {:?}", err)), + Ok(token_response) => { + let oidc_nonce = Nonce::new(nonce.nonce); + + let id_token = match token_response.extra_fields().id_token() { + None => err!("Token response did not contain an id_token"), + Some(token) => token, + }; + + if CONFIG.sso_debug_tokens() { + debug!("Id token: {}", id_token.to_string()); + debug!("Access token: {}", token_response.access_token().secret()); + debug!("Refresh token: {:?}", token_response.refresh_token().map(|t| t.secret())); + debug!("Expiration time: {:?}", token_response.expires_in()); + } + + let id_claims = match id_token.claims(&self.vw_id_token_verifier(), &oidc_nonce) { + Ok(claims) => claims.clone(), + Err(err) => { + Self::invalidate(); + err!(format!("Could not read id_token claims, {err}")); + } + }; + + Ok((token_response, id_claims)) + } + } + } + + pub async fn user_info(&self, access_token: AccessToken) -> ApiResult { + match self.core_client.user_info(access_token, None).request_async(&self.http_client).await { + Err(err) => err!(format!("Request to user_info endpoint failed: {err}")), + Ok(user_info) => Ok(user_info), + } + } + + pub async fn check_validaty(access_token: String) -> EmptyResult { + let client = Client::cached().await?; + match client.user_info(AccessToken::new(access_token)).await { + Err(err) => { + err_silent!(format!("Failed to retrieve user info, token has probably been invalidated: {err}")) + } + Ok(_) => Ok(()), + } + } + + pub fn vw_id_token_verifier(&self) -> CoreIdTokenVerifier<'_> { + let mut verifier = self.core_client.id_token_verifier(); + if let Some(regex_str) = CONFIG.sso_audience_trusted() { + match Regex::new(®ex_str) { + Ok(regex) => { + verifier = verifier.set_other_audience_verifier_fn(move |aud| regex.is_match(aud)); + } + Err(err) => { + error!("Failed to parse SSO_AUDIENCE_TRUSTED={regex_str} regex: {err}"); + } + } + } + verifier + } + + pub async fn exchange_refresh_token( + refresh_token: String, + ) -> ApiResult<(Option, String, Option)> { + let rt = RefreshToken::new(refresh_token); + + let client = Client::cached().await?; + let token_response = + match client.core_client.exchange_refresh_token(&rt).request_async(&client.http_client).await { + Err(err) => err!(format!("Request to exchange_refresh_token endpoint failed: {:?}", err)), + Ok(token_response) => token_response, + }; + + Ok(( + token_response.refresh_token().map(|token| token.secret().clone()), + token_response.access_token().secret().clone(), + token_response.expires_in(), + )) + } +} + +trait AuthorizationRequestExt<'a> { + fn add_extra_params>, V: Into>>(self, params: Vec<(N, V)>) -> Self; +} + +impl<'a, AD: AuthDisplay, P: AuthPrompt, RT: ResponseType> AuthorizationRequestExt<'a> + for AuthorizationRequest<'a, AD, P, RT> +{ + fn add_extra_params>, V: Into>>(mut self, params: Vec<(N, V)>) -> Self { + for (key, value) in params { + self = self.add_extra_param(key, value); + } + self + } +} diff --git a/src/static/scripts/admin.js b/src/static/scripts/admin.js index b194a91d..06d6ca5c 100644 --- a/src/static/scripts/admin.js +++ b/src/static/scripts/admin.js @@ -28,11 +28,11 @@ function msg(text, reload_page = true) { reload_page && reload(); } -function _post(url, successMsg, errMsg, body, reload_page = true) { +function _fetch(method, url, successMsg, errMsg, body, reload_page = true) { let respStatus; let respStatusText; fetch(url, { - method: "POST", + method: method, body: body, mode: "same-origin", credentials: "same-origin", @@ -65,6 +65,14 @@ function _post(url, successMsg, errMsg, body, reload_page = true) { }); } +function _post(url, successMsg, errMsg, body, reload_page = true) { + return _fetch("POST", url, successMsg, errMsg, body, reload_page); +} + +function _delete(url, successMsg, errMsg, body, reload_page = true) { + return _fetch("DELETE", url, successMsg, errMsg, body, reload_page); +} + // Bootstrap Theme Selector const getStoredTheme = () => localStorage.getItem("theme"); const setStoredTheme = theme => localStorage.setItem("theme", theme); @@ -146,4 +154,4 @@ document.addEventListener("DOMContentLoaded", (/*event*/) => { navItem[0].className = navItem[0].className + " active"; navItem[0].setAttribute("aria-current", "page"); } -}); \ No newline at end of file +}); diff --git a/src/static/scripts/admin_users.js b/src/static/scripts/admin_users.js index 54fdedf2..71015d1d 100644 --- a/src/static/scripts/admin_users.js +++ b/src/static/scripts/admin_users.js @@ -24,6 +24,28 @@ function deleteUser(event) { } } +function deleteSSOUser(event) { + event.preventDefault(); + event.stopPropagation(); + const id = event.target.parentNode.dataset.vwUserUuid; + const email = event.target.parentNode.dataset.vwUserEmail; + if (!id || !email) { + alert("Required parameters not found!"); + return false; + } + const input_email = prompt(`To delete user "${email}", please type the email below`); + if (input_email != null) { + if (input_email == email) { + _delete(`${BASE_URL}/admin/users/${id}/sso`, + "User SSO Associtation deleted correctly", + "Error deleting user SSO association" + ); + } else { + alert("Wrong email, please try again"); + } + } +} + function remove2fa(event) { event.preventDefault(); event.stopPropagation(); @@ -246,6 +268,9 @@ function initUserTable() { document.querySelectorAll("button[vw-delete-user]").forEach(btn => { btn.addEventListener("click", deleteUser); }); + document.querySelectorAll("button[vw-delete-sso-user]").forEach(btn => { + btn.addEventListener("click", deleteSSOUser); + }); document.querySelectorAll("button[vw-disable-user]").forEach(btn => { btn.addEventListener("click", disableUser); }); @@ -263,6 +288,8 @@ function initUserTable() { // onLoad events document.addEventListener("DOMContentLoaded", (/*event*/) => { + const size = jQuery("#users-table > thead th").length; + const ssoOffset = size-7; jQuery("#users-table").DataTable({ "drawCallback": function() { initUserTable(); @@ -275,10 +302,10 @@ document.addEventListener("DOMContentLoaded", (/*event*/) => { ], "pageLength": -1, // Default show all "columnDefs": [{ - "targets": [1, 2], + "targets": [1 + ssoOffset, 2 + ssoOffset], "type": "date-iso" }, { - "targets": 6, + "targets": size-1, "searchable": false, "orderable": false }] @@ -303,4 +330,4 @@ document.addEventListener("DOMContentLoaded", (/*event*/) => { if (btnInviteUserForm) { btnInviteUserForm.addEventListener("submit", inviteUser); } -}); \ No newline at end of file +}); diff --git a/src/static/templates/admin/users.hbs b/src/static/templates/admin/users.hbs index 19e489c1..52458012 100644 --- a/src/static/templates/admin/users.hbs +++ b/src/static/templates/admin/users.hbs @@ -6,6 +6,9 @@ User + {{#if sso_enabled}} + SSO Identifier + {{/if}} Created at Last Active Entries @@ -38,6 +41,11 @@ + {{#if ../sso_enabled}} + + {{sso_identifier}} + + {{/if}} {{created_at}} @@ -67,6 +75,9 @@ {{/if}}

+ {{#if ../sso_enabled}} +
+ {{/if}} {{#if user_enabled}}
{{else}} diff --git a/src/static/templates/email/send_org_invite.html.hbs b/src/static/templates/email/send_org_invite.html.hbs index ce3a6c05..8fc6ccf6 100644 --- a/src/static/templates/email/send_org_invite.html.hbs +++ b/src/static/templates/email/send_org_invite.html.hbs @@ -9,7 +9,7 @@ Join {{{org_name}}} - Join Organization Now diff --git a/src/static/templates/email/sso_change_email.hbs b/src/static/templates/email/sso_change_email.hbs new file mode 100644 index 00000000..5a512280 --- /dev/null +++ b/src/static/templates/email/sso_change_email.hbs @@ -0,0 +1,4 @@ +Your Email Changed + +Your email was changed in your SSO Provider. Please update your email in Account Settings ({{url}}). +{{> email/email_footer_text }} diff --git a/src/static/templates/email/sso_change_email.html.hbs b/src/static/templates/email/sso_change_email.html.hbs new file mode 100644 index 00000000..74cd445c --- /dev/null +++ b/src/static/templates/email/sso_change_email.html.hbs @@ -0,0 +1,11 @@ +Your Email Changed + +{{> email/email_header }} + + + + +
+ Your email was changed in your SSO Provider. Please update your email in Account Settings. +
+{{> email/email_footer }} diff --git a/src/static/templates/email/twofactor_email.html.hbs b/src/static/templates/email/twofactor_email.html.hbs index 30990d9e..672daa32 100644 --- a/src/static/templates/email/twofactor_email.html.hbs +++ b/src/static/templates/email/twofactor_email.html.hbs @@ -4,7 +4,7 @@ Vaultwarden Login Verification Code diff --git a/src/static/templates/email/verify_email.html.hbs b/src/static/templates/email/verify_email.html.hbs index c37cf36d..29a1377f 100644 --- a/src/static/templates/email/verify_email.html.hbs +++ b/src/static/templates/email/verify_email.html.hbs @@ -9,7 +9,7 @@ Verify Your Email
- Your two-step verification code is: {{token}} + Your two-step verification code is: {{token}}
- Verify Email Address Now diff --git a/src/static/templates/scss/vaultwarden.scss.hbs b/src/static/templates/scss/vaultwarden.scss.hbs index 8f86881a..143a1599 100644 --- a/src/static/templates/scss/vaultwarden.scss.hbs +++ b/src/static/templates/scss/vaultwarden.scss.hbs @@ -21,15 +21,33 @@ a[href$="/settings/sponsored-families"] { } /* Hide the sso `Email` input field */ +{{#if sso_disabled}} .vw-email-sso { @extend %vw-hide; } +{{/if}} + +/* Hide the default/continue `Email` input field */ +{{#if (not sso_disabled)}} +.vw-email-continue { + @extend %vw-hide; +} +{{/if}} + +/* Hide the `Continue` button on the login page */ +{{#if (not sso_disabled)}} +.vw-continue-login { + @extend %vw-hide; +} +{{/if}} /* Hide the `Enterprise Single Sign-On` button on the login page */ {{#if (webver ">=2025.5.1")}} +{{#if sso_disabled}} .vw-sso-login { @extend %vw-hide; } +{{/if}} {{else}} app-root ng-component > form > div:nth-child(1) > div > button[buttontype="secondary"].\!tw-text-primary-600:nth-child(4) { @extend %vw-hide; @@ -53,9 +71,11 @@ app-root ng-component > form > div:nth-child(1) > div > button[buttontype="secon /* Hide the or text followed by the two buttons hidden above */ {{#if (webver ">=2025.5.1")}} +{{#if (or sso_disabled sso_only)}} .vw-or-text { @extend %vw-hide; } +{{/if}} {{else}} app-root ng-component > form > div:nth-child(1) > div:nth-child(3) > div:nth-child(2) { @extend %vw-hide; @@ -63,9 +83,11 @@ app-root ng-component > form > div:nth-child(1) > div:nth-child(3) > div:nth-chi {{/if}} /* Hide the `Other` button on the login page */ +{{#if (or sso_disabled sso_only)}} .vw-other-login { @extend %vw-hide; } +{{/if}} /* Hide Two-Factor menu in Organization settings */ bit-nav-item[route="settings/two-factor"], diff --git a/src/util.rs b/src/util.rs index 904c1e08..3048ff92 100644 --- a/src/util.rs +++ b/src/util.rs @@ -151,9 +151,12 @@ impl Cors { // If a match exists, return it. Otherwise, return None. fn get_allowed_origin(headers: &HeaderMap<'_>) -> Option { let origin = Cors::get_header(headers, "Origin"); - let domain_origin = CONFIG.domain_origin(); let safari_extension_origin = "file://"; - if origin == domain_origin || origin == safari_extension_origin { + + if origin == CONFIG.domain_origin() + || origin == safari_extension_origin + || (CONFIG.sso_enabled() && origin == CONFIG.sso_authority()) + { Some(origin) } else { None