diff --git a/.env.template b/.env.template index 80eb475620..05b640e4f1 100644 --- a/.env.template +++ b/.env.template @@ -161,6 +161,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 ### @@ -444,6 +448,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 77eabf5b2b..7a5f936765 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -326,6 +326,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" @@ -369,6 +375,12 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "383d29d513d8764dcdc42ea295d979eb99c3c9f00607b3692cf68a431f7dca72" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.8.0" @@ -433,6 +445,12 @@ version = "3.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" +[[package]] +name = "bytecount" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ce89b21cab1437276d2650d57e971f9d548a2d9037cc231abdc0562b97498ce" + [[package]] name = "bytemuck" version = "1.21.0" @@ -487,11 +505,42 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0" +[[package]] +name = "camino" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b96ec4966b5813e2c0507c1f86115c8c5abaadc3980879c3424042a02fd1ad3" +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 = "cc" -version = "1.2.11" +version = "1.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4730490333d58093109dc02c23174c3f4d490998c3fed3cc8e82d57afedb9cf" +checksum = "755717a7de9ec452bf7f3f1a3099085deabd7f2962b861dae91ecd7a365903d2" dependencies = [ "shlex", ] @@ -510,8 +559,10 @@ checksum = "7e36cc9d416881d2e24f9a963be5fb1cd90966419ac844274161d10488b3e825" dependencies = [ "android-tzdata", "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-targets 0.52.6", ] @@ -561,6 +612,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "cookie" version = "0.18.1" @@ -635,12 +692,33 @@ dependencies = [ "once_cell", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06ba6d68e24814cb8de6bb986db8222d3a027d15872cabc0d18817bc3c0e4471" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[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" @@ -651,6 +729,33 @@ dependencies = [ "typenum", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +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.10" @@ -686,6 +791,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" @@ -712,6 +830,17 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c297a1c74b71ae29df00c3e22dd9534821d60eb9af5a0192823fa2acea70c2a" +[[package]] +name = "der" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f55bf8e7b65898637379c1b74eb1551107c8294ed26d855ceb9fd1a09cfc9bc0" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.3.11" @@ -719,6 +848,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" dependencies = [ "powerfmt", + "serde", ] [[package]] @@ -754,18 +884,18 @@ dependencies = [ [[package]] name = "derive_more" -version = "2.0.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71158d5e914dec8a242751a3fc516b03ed3e6772ce9de79e1aeea6420663cad4" +checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" dependencies = [ "derive_more-impl", ] [[package]] name = "derive_more-impl" -version = "2.0.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e04e066e440d7973a852a3acdc25b0ae712bb6d311755fbf773d6a4518b2226" +checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" dependencies = [ "proc-macro2", "quote", @@ -799,7 +929,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b035a542cf7abf01f2e3c4d5a7acbaebfefe120ae4efc7bde3df98186e4b8af7" dependencies = [ - "bitflags", + "bitflags 2.8.0", "proc-macro2", "proc-macro2-diagnostics", "quote", @@ -813,7 +943,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04001f23ba8843dc315804fa324000376084dfb1c30794ff68dd279e6e5696d5" dependencies = [ "bigdecimal", - "bitflags", + "bitflags 2.8.0", "byteorder", "chrono", "diesel_derives", @@ -891,6 +1021,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", "subtle", ] @@ -935,12 +1066,77 @@ dependencies = [ "syn", ] +[[package]] +name = "dyn-clone" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "feeef44e73baff3a26d371801df019877a9866a8c493d315ab00177843314f35" + +[[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.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a3daa8e81a3963a60642bcc1f90a670680bd4a77535faa384e9d1c79d620871" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +[[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.3.1" @@ -1003,6 +1199,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[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" @@ -1048,6 +1253,22 @@ dependencies = [ "syslog", ] +[[package]] +name = "ff" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ded41244b729663b1e574f1b4fb731469f69f79c17667b5d776b16cda0479449" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "figment" version = "0.10.19" @@ -1231,6 +1452,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1289,7 +1511,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "842dc78579ce01e6a1576ad896edc92fca002dd60c9c3746b7fc2bec6fb429d0" dependencies = [ "cfg-if", - "dashmap", + "dashmap 6.1.0", "futures-sink", "futures-timer", "futures-util", @@ -1310,12 +1532,42 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d9e3df7f0222ce5184154973d247c591d9aadc28ce7a73c6cd31100c9facff6" dependencies = [ "codemap", - "indexmap", + "indexmap 2.7.1", "lasso", "once_cell", "phf", ] +[[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.3.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap 2.7.1", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "h2" version = "0.4.7" @@ -1328,7 +1580,7 @@ dependencies = [ "futures-core", "futures-sink", "http 1.2.0", - "indexmap", + "indexmap 2.7.1", "slab", "tokio", "tokio-util", @@ -1358,6 +1610,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" @@ -1392,6 +1650,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hickory-proto" version = "0.24.2" @@ -1437,6 +1701,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" @@ -1555,6 +1828,7 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", + "h2 0.3.26", "http 0.2.12", "http-body 0.4.6", "httparse", @@ -1577,7 +1851,7 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "h2", + "h2 0.4.7", "http 1.2.0", "http-body 1.0.1", "httparse", @@ -1588,6 +1862,20 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.32", + "rustls 0.21.12", + "tokio", + "tokio-rustls 0.24.1", +] + [[package]] name = "hyper-rustls" version = "0.27.5" @@ -1808,6 +2096,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.7.1" @@ -1854,6 +2153,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.14" @@ -1925,6 +2233,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "lettre" @@ -2122,6 +2433,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" @@ -2236,6 +2562,23 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" +dependencies = [ + "byteorder", + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -2262,6 +2605,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-modular" version = "0.6.1" @@ -2284,6 +2638,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -2305,6 +2660,26 @@ dependencies = [ "libc", ] +[[package]] +name = "oauth2" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c38841cdd844847e3e7c8d29cef9dcfed8877f8f56f9071f77843ecf3baf937f" +dependencies = [ + "base64 0.13.1", + "chrono", + "getrandom 0.2.15", + "http 0.2.12", + "rand 0.8.5", + "reqwest 0.11.27", + "serde", + "serde_json", + "serde_path_to_error", + "sha2", + "thiserror 1.0.69", + "url", +] + [[package]] name = "object" version = "0.36.7" @@ -2320,13 +2695,45 @@ version = "1.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" +[[package]] +name = "openidconnect" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f47e80a9cfae4462dd29c41e987edd228971d6565553fbc14b8a11e666d91590" +dependencies = [ + "base64 0.13.1", + "chrono", + "dyn-clone", + "ed25519-dalek", + "hmac", + "http 0.2.12", + "itertools", + "log", + "oauth2", + "p256", + "p384", + "rand 0.8.5", + "rsa", + "serde", + "serde-value", + "serde_derive", + "serde_json", + "serde_path_to_error", + "serde_plain", + "serde_with", + "sha2", + "subtle", + "thiserror 1.0.69", + "url", +] + [[package]] name = "openssl" version = "0.10.70" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61cfb4e166a8bb8c9b55c500bc2308550148ece889be90f609377e58140f42c6" dependencies = [ - "bitflags", + "bitflags 2.8.0", "cfg-if", "foreign-types", "libc", @@ -2374,12 +2781,45 @@ 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 = "overload" 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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70786f51bcc69f6a4c0360e063a4cac5419ef7c5cd5b3c99ad70f3be5ba79209" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + [[package]] name = "parking" version = "2.2.1" @@ -2468,6 +2908,15 @@ dependencies = [ "serde", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.1" @@ -2600,6 +3049,27 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.31" @@ -2652,6 +3122,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.93" @@ -2699,6 +3178,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 2.8.0", + "memchr", + "unicase", +] + [[package]] name = "quanta" version = "0.12.5" @@ -2765,7 +3255,7 @@ checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.0", - "zerocopy 0.8.14", + "zerocopy 0.8.15", ] [[package]] @@ -2804,7 +3294,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b08f3c9802962f7e1b25113931d94f43ed9725bebc59db9d0c3e9a23b67e15ff" dependencies = [ "getrandom 0.3.1", - "zerocopy 0.8.14", + "zerocopy 0.8.15", ] [[package]] @@ -2813,7 +3303,7 @@ version = "11.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6928fa44c097620b706542d428957635951bade7143269085389d42c8a4927e" dependencies = [ - "bitflags", + "bitflags 2.8.0", ] [[package]] @@ -2822,7 +3312,7 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834" dependencies = [ - "bitflags", + "bitflags 2.8.0", ] [[package]] @@ -2900,6 +3390,47 @@ dependencies = [ "signal-hook", ] +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64 0.21.7", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2 0.3.26", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-rustls 0.24.2", + "ipnet", + "js-sys", + "log", + "mime", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls 0.21.12", + "rustls-pemfile 1.0.4", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 0.1.2", + "system-configuration 0.5.1", + "tokio", + "tokio-rustls 0.24.1", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", + "winreg", +] + [[package]] name = "reqwest" version = "0.12.12" @@ -2915,12 +3446,12 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "h2", + "h2 0.4.7", "http 1.2.0", "http-body 1.0.1", "http-body-util", "hyper 1.6.0", - "hyper-rustls", + "hyper-rustls 0.27.5", "hyper-tls", "hyper-util", "ipnet", @@ -2935,8 +3466,8 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "sync_wrapper", - "system-configuration", + "sync_wrapper 1.0.2", + "system-configuration 0.6.1", "tokio", "tokio-native-tls", "tokio-socks", @@ -2961,6 +3492,16 @@ dependencies = [ "quick-error", ] +[[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.8" @@ -3011,7 +3552,7 @@ dependencies = [ "either", "figment", "futures", - "indexmap", + "indexmap 2.7.1", "log", "memchr", "multer", @@ -3043,7 +3584,7 @@ checksum = "575d32d7ec1a9770108c879fc7c47815a80073f96ca07ff9525a94fcede1dd46" dependencies = [ "devise", "glob", - "indexmap", + "indexmap 2.7.1", "proc-macro2", "quote", "rocket_http", @@ -3063,7 +3604,7 @@ dependencies = [ "futures", "http 0.2.12", "hyper 0.14.32", - "indexmap", + "indexmap 2.7.1", "log", "memchr", "pear", @@ -3103,6 +3644,26 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "rsa" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c75d7c5c6b673e58bf54d8544a9f432e3a925b0e80f7cd3602ab5c50c55519" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rtoolbox" version = "0.0.2" @@ -3119,13 +3680,22 @@ version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags", + "bitflags 2.8.0", "errno", "libc", "linux-raw-sys", @@ -3263,13 +3833,27 @@ 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", + "bitflags 2.8.0", "core-foundation", "core-foundation-sys", "libc", @@ -3291,6 +3875,9 @@ name = "semver" version = "1.0.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f79dfe2d285b0488816f30e700a7438c5a73d816b5b7d3ac72fbc48b0d185e03" +dependencies = [ + "serde", +] [[package]] name = "serde" @@ -3301,6 +3888,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" @@ -3334,6 +3931,25 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af99884400da37c88f5e9146b7f1fd0fbcae8f6eec4e9da38b67d05486f814a6" +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.8" @@ -3355,6 +3971,36 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6b6f7f2fcb69f747921f79f3926bd1e203fce4fef62c268dd3abfb6d86029aa" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.7.1", + "serde", + "serde_derive", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d00caa5193a3c8362ac2b73be6b9e768aa5a4b2f721d8f4b339600c3cb51f8e" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sha1" version = "0.10.6" @@ -3411,6 +4057,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + [[package]] name = "simple_asn1" version = "0.6.3" @@ -3429,6 +4085,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.9" @@ -3469,6 +4140,16 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable-pattern" version = "0.1.0" @@ -3529,6 +4210,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -3561,15 +4248,36 @@ dependencies = [ "time", ] +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "system-configuration-sys 0.5.0", +] + [[package]] name = "system-configuration" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags", + "bitflags 2.8.0", "core-foundation", - "system-configuration-sys", + "system-configuration-sys 0.6.0", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", ] [[package]] @@ -3582,6 +4290,12 @@ dependencies = [ "libc", ] +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "tempfile" version = "3.16.0" @@ -3847,7 +4561,7 @@ version = "0.22.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02a8b472d1a3d7c18e2d61a489aee3453fd9031c33e4f55bd533f4a7adca1bee" dependencies = [ - "indexmap", + "indexmap 2.7.1", "serde", "serde_spanned", "toml_datetime", @@ -3875,7 +4589,7 @@ dependencies = [ "futures-core", "futures-util", "pin-project-lite", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tower-layer", "tower-service", @@ -3955,6 +4669,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" @@ -4011,6 +4731,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.16" @@ -4092,7 +4818,7 @@ dependencies = [ "chrono-tz", "cookie", "cookie_store", - "dashmap", + "dashmap 6.1.0", "data-encoding", "data-url", "derive_more", @@ -4116,16 +4842,18 @@ dependencies = [ "log", "macros", "mimalloc", + "mini-moka", "num-derive", "num-traits", "once_cell", + "openidconnect", "openssl", "paste", "percent-encoding", "pico-args", "rand 0.9.0", "regex", - "reqwest", + "reqwest 0.12.12", "ring", "rmpv", "rocket", @@ -4315,6 +5043,12 @@ dependencies = [ "url", ] +[[package]] +name = "webpki-roots" +version = "0.25.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" + [[package]] name = "which" version = "7.0.1" @@ -4601,7 +5335,7 @@ version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c" dependencies = [ - "bitflags", + "bitflags 2.8.0", ] [[package]] @@ -4659,7 +5393,7 @@ dependencies = [ "futures", "hmac", "rand 0.8.5", - "reqwest", + "reqwest 0.12.12", "sha1", "threadpool", ] @@ -4676,11 +5410,11 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.14" +version = "0.8.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a367f292d93d4eab890745e75a778da40909cab4d6ff8173693812f79c4a2468" +checksum = "a1e101d4bc320b6f9abb68846837b70e25e380ca2f467ab494bf29fcc435fcc3" dependencies = [ - "zerocopy-derive 0.8.14", + "zerocopy-derive 0.8.15", ] [[package]] @@ -4696,9 +5430,9 @@ dependencies = [ [[package]] name = "zerocopy-derive" -version = "0.8.14" +version = "0.8.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3931cb58c62c13adec22e38686b559c86a30565e16ad6e8510a337cedc611e1" +checksum = "03a73df1008145cd135b3c780d275c57c3e6ba8324a41bd5e0008fe167c3bc7c" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 2542e3c670..fade316658 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -156,6 +156,10 @@ pico-args = "0.5.0" paste = "1.0.15" governor = "0.8.0" +# OIDC for SSO +openidconnect = "3.5.0" +mini-moka = "0.10.2" + # Check client versions for specific features. semver = "1.0.25" diff --git a/SSO.md b/SSO.md new file mode 100644 index 0000000000..b113db6121 --- /dev/null +++ b/SSO.md @@ -0,0 +1,293 @@ +# 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 (default `false`, `LOG_LEVEL=debug` is required) + +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 Vaultwarden 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#IDToken)). + - 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 to Vaultwarden 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 in Vaultwarden 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 `VaultWarden` 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 `Vaultwarden` 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 `VaultWarden` 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}` + +## 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` + +It appears it's not possible to use PKCE with confidential client so it needs to be disabled. + +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}$'` +- `SSO_PKCE=false` + +## 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 VaultWarden 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 VaultWarden 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 0000000000..2c946dc512 --- /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 0000000000..518664df7d --- /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/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 0000000000..bce3122209 --- /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 0000000000..f73aeea914 --- /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 0000000000..c033f7cbce --- /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 0000000000..42fb0efa5b --- /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 0000000000..f2f92f6822 --- /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 0000000000..7809d43e93 --- /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 0000000000..e69de29bb2 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 0000000000..4e06fe58e1 --- /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 0000000000..2c946dc512 --- /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 0000000000..1321e24653 --- /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/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 0000000000..7cf4d9d6be --- /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 0000000000..f7402460e5 --- /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 0000000000..ef209a455e --- /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 0000000000..f2dedfc92e --- /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 0000000000..f2f92f6822 --- /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 0000000000..b74b57285f --- /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 0000000000..e69de29bb2 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 0000000000..38f97b4de5 --- /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 0000000000..2c946dc512 --- /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 0000000000..518664df7d --- /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/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 0000000000..3cbd460249 --- /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 0000000000..13e95fd84c --- /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 0000000000..e7a55bd801 --- /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 0000000000..6b55e95d34 --- /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 0000000000..f2f92f6822 --- /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 0000000000..6d015f0418 --- /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 0000000000..e69de29bb2 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 0000000000..53b09cf4d0 --- /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 0000000000..5b6c0c9e23 --- /dev/null +++ b/playwright/.env.template @@ -0,0 +1,63 @@ +################################# +### 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} +I_REALLY_WANT_VOLATILE_STORAGE=true + +SSO_ENABLED=true +SSO_ONLY=false +SSO_CLIENT_ID=VaultWarden +SSO_CLIENT_SECRET=VaultWarden +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 0000000000..8746d597aa --- /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 0000000000..c470fbae52 --- /dev/null +++ b/playwright/README.md @@ -0,0 +1,177 @@ +# 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 and the default `web-vault` you'll need to make the SSO button visible using : + +```bash +sed -i 's#a\[routerlink="/sso"\],##' web-vault/app/main.*.css +``` + +Otherwise you'll need to reveal the SSO login button using the debug console (F12) + + ```js + document.querySelector('a[routerlink="/sso"]').style.setProperty("display", "inline-block", "important"); + ``` + +## 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 0000000000..3588895016 --- /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 0000000000..36597b1d21 --- /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 0000000000..1a4b1ddb7f --- /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/vaultwarden/Dockerfile b/playwright/compose/vaultwarden/Dockerfile new file mode 100644 index 0000000000..4606ae36c2 --- /dev/null +++ b/playwright/compose/vaultwarden/Dockerfile @@ -0,0 +1,39 @@ +FROM playwright_oidc_vaultwarden_prebuilt AS vaultwarden + +FROM node:18-bookworm AS build + +arg REPO_URL +arg COMMIT_HASH + +ENV REPO_URL=$REPO_URL +ENV COMMIT_HASH=$COMMIT_HASH + +COPY --from=vaultwarden /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=vaultwarden /start.sh . +COPY --from=vaultwarden /vaultwarden . +COPY --from=build /web-vault ./web-vault + +ENTRYPOINT ["/start.sh"] diff --git a/playwright/compose/vaultwarden/build.sh b/playwright/compose/vaultwarden/build.sh new file mode 100755 index 0000000000..da35411291 --- /dev/null +++ b/playwright/compose/vaultwarden/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 0000000000..ea2ab5e942 --- /dev/null +++ b/playwright/docker-compose.yml @@ -0,0 +1,121 @@ +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/vaultwarden + 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 + - SMTP_HOST + - SMTP_FROM + - SMTP_DEBUG + - 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 + 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 0000000000..89405f125f --- /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 0000000000..724b08772f --- /dev/null +++ b/playwright/global-utils.ts @@ -0,0 +1,219 @@ +import { 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 function closeMails(mailServer: MailServer, mailIterators: AsyncIterator[]) { + if( mailServer ) { + mailServer.close(); + } + if( mailIterators ) { + for (const mails of mailIterators) { + if(mails){ + mails.return(); + } + } + } +} + +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": 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": 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": return { + DATABASE_URL: `mysql://${process.env.MYSQL_USER}:${process.env.MYSQL_PASSWORD}@127.0.0.1:${process.env.MYSQL_PORT}/${process.env.MYSQL_DATABASE}` + } + default: return { I_REALLY_WANT_VOLATILE_STORAGE: true } + } +} + +/** + * All parameters passed in `env` need to be added to the docker-compose.yml + **/ +export async function startVaultwarden(browser: Browser, testInfo: TestInfo, env = {}, resetDB: Boolean = true) { + if( resetDB ){ + switch(testInfo.project.name) { + case "postgres": + await wipePostgres(); + break; + case "mariadb": + await wipeMariaDB(); + break; + case "mysql": + await wipeMysqlDB(); + break; + default: + wipeSqlite(); + } + } + + 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 stopVaultwarden() { + console.log(`Vaultwarden stopping`); + execSync(`docker compose --profile playwright --env-file test.env stop Vaultwarden`); +} + +export async function restartVaultwarden(page: Page, testInfo: TestInfo, env, resetDB: Boolean = true) { + stopVaultwarden(); + return startVaultwarden(page.context().browser(), testInfo, env, resetDB); +} diff --git a/playwright/package-lock.json b/playwright/package-lock.json new file mode 100644 index 0000000000..9225899df8 --- /dev/null +++ b/playwright/package-lock.json @@ -0,0 +1,2364 @@ +{ + "name": "scenarios", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "scenarios", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "mysql2": "^3.12.0", + "otpauth": "^9.3.6", + "pg": "^8.13.1" + }, + "devDependencies": { + "@playwright/test": "^1.49.1", + "dotenv": "^16.4.7", + "dotenv-expand": "^11.0.7", + "maildev": "github:timshel/maildev#3.0.2" + } + }, + "node_modules/@noble/hashes": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.6.1.tgz", + "integrity": "sha512-pq5D8h10hHBjyqX+cfBm0i8JUXJ0UhczFc4r74zbuT9XgewFo2E3J1cOaGtdZynILNmQ685YWGzGE1Zv6io50w==", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@playwright/test": { + "version": "1.49.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.49.1.tgz", + "integrity": "sha512-Ky+BVzPz8pL6PQxHqNRW1k3mIyv933LML7HktS8uik0bUXNCdPhoS/kLihiO1tMf/egaJb4IutXd7UywvXEW+g==", + "dev": true, + "dependencies": { + "playwright": "1.49.1" + }, + "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/cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==", + "dev": true + }, + "node_modules/@types/cors": { + "version": "2.8.17", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz", + "integrity": "sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/mailparser": { + "version": "3.4.5", + "resolved": "https://registry.npmjs.org/@types/mailparser/-/mailparser-3.4.5.tgz", + "integrity": "sha512-EPERBp7fLeFZh7tS2X36MF7jawUx3Y6/0rXciZah3CTYgwLi3e0kpGUJ6FOmUabgzis/U1g+3/JzrVWbWIOGjg==", + "dev": true, + "dependencies": { + "@types/node": "*", + "iconv-lite": "^0.6.3" + } + }, + "node_modules/@types/node": { + "version": "22.10.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.5.tgz", + "integrity": "sha512-F8Q+SeGimwOo86fiovQh8qiXfFEh2/ocYv7tU5pJ3EXMSSxk1Joj5wefpFK2fHTf/N6HKGSxIDBT9f3gCxXPkQ==", + "dev": true, + "dependencies": { + "undici-types": "~6.20.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.1", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz", + "integrity": "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.3.tgz", + "integrity": "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==", + "dev": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "get-intrinsic": "^1.2.6" + }, + "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.7.5", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.5.tgz", + "integrity": "sha512-bQJ0YRck5ak3LgtnpKkiabX5pNF7tMUh1BSy2ZBOTh0Dim0BUu6aPPwByIns6/A5Prh8PufSPerMDUklpzes2Q==", + "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.1.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.1.0.tgz", + "integrity": "sha512-h66W1URKpBS5YMI/V8PyXvTMFT8SupJ1IzoIV8IeBC/ji8WVmrO8dGlTi+2dh6whmdk6BiKJLD/ZBkhWbcg6nA==", + "dev": true, + "dependencies": { + "rrweb-cssom": "^0.7.1" + }, + "engines": { + "node": ">=18" + } + }, + "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.4.3", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", + "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==", + "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.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.3.tgz", + "integrity": "sha512-U1U5Hzc2MO0oW3DF+G9qYN0aT7atAou4AgI0XjWz061nyBPbdxkfdhfy5uMgGn6+oLFCfn44ZGbdDqCzVmlOWA==", + "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.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", + "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", + "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.2", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.2.tgz", + "integrity": "sha512-gmNvsYi9C8iErnZdVcJnvCpSKbWTt1E8+JZo8b+daLninywUWi5NQ5STSHZ9rFjFO7imNcvb8Pc5pe/wMR5xEw==", + "dev": true, + "dependencies": { + "@types/cookie": "^0.4.1", + "@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.0.0", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", + "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0" + }, + "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.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", + "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", + "dev": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "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.2.7", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz", + "integrity": "sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA==", + "dev": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "function-bind": "^1.1.2", + "get-proto": "^1.0.0", + "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/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.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "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.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "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.2.3", + "resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz", + "integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==" + }, + "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/lru.min": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.1.tgz", + "integrity": "sha512-FbAj6lXil6t8z4z3j0E5mfRlPzxkySotzUHwRXjlpRh10vc6AI6WN62ehZj82VG7M20rqogJ0GLwar2Xa05a8Q==", + "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": { + "version": "3.0.2", + "resolved": "git+ssh://git@github.com/timshel/maildev.git#b76b02f184a2b56115ccdf477e81d6532c763994", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mailparser": "^3.4.4", + "addressparser": "1.0.1", + "async": "^3.2.3", + "commander": "^12.1.0", + "compression": "^1.7.4", + "cors": "^2.8.5", + "dompurify": "^3.2.1", + "express": "^4.21.1", + "jsdom": "^24.1.1", + "mailparser": "^3.7.1", + "mime": "1.6.0", + "nodemailer": "^6.9.14", + "smtp-server": "^3.13.4", + "socket.io": "^4.8.1", + "wildstring": "1.0.9" + }, + "bin": { + "maildev": "bin/maildev" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/mailparser": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.7.2.tgz", + "integrity": "sha512-iI0p2TCcIodR1qGiRoDBBwboSSff50vQAWytM5JRggLfABa4hHYCf3YVujtuzV454xrOP352VsAPIzviqMTo4Q==", + "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.2", + "nodemailer": "6.9.16", + "punycode.js": "2.3.1", + "tlds": "1.255.0" + } + }, + "node_modules/mailsplit": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/mailsplit/-/mailsplit-5.4.2.tgz", + "integrity": "sha512-4cczG/3Iu3pyl8JgQ76dKkisurZTmxMrA4dj/e8d2jKYcFTZ7MxOzg1gTioTDMPuFXwTrVuN/gxhkrO7wLg7qA==", + "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.53.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.53.0.tgz", + "integrity": "sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg==", + "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.12.0", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.12.0.tgz", + "integrity": "sha512-C8fWhVysZoH63tJbX8d10IAoYCyXy4fdRFz2Ihrt9jtPILYynFEKUUzpp1U7qxzDc3tMbotvaBH+sl6bFnGZiw==", + "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/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.9.16", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.16.tgz", + "integrity": "sha512-psAuZdTIRN08HKVd/E8ObdV6NO7NTBY3KsC30F7M4H1OnmLCUNaS56FpYxyb26zWLSyYF9Ozch9KYHhHegsiOQ==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/nwsapi": { + "version": "2.2.16", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.16.tgz", + "integrity": "sha512-F1I/bimDpj3ncaNDhfyMWuFqmQDBwDB0Fogc2qpL3BWvkQteFD/8BzWuIRl83rq0DXfm8SGt/HFhLXZyljTXcQ==", + "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.3", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz", + "integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==", + "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.3.6", + "resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.3.6.tgz", + "integrity": "sha512-eIcCvuEvcAAPHxUKC9Q4uCe0Fh/yRc5jv9z+f/kvyIF2LPrhgAOuLB7J9CssGYhND/BL8M9hlHBTFmffpoQlMQ==", + "dependencies": { + "@noble/hashes": "1.6.1" + }, + "funding": { + "url": "https://github.com/hectorm/otpauth?sponsor=1" + } + }, + "node_modules/parse5": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.2.1.tgz", + "integrity": "sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==", + "dev": true, + "dependencies": { + "entities": "^4.5.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?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.13.1", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.13.1.tgz", + "integrity": "sha512-OUir1A0rPNZlX//c7ksiu7crsGZTKSOXJPgtNiHGIlC9H0lO+NC6ZDYksSgBYY/thSWhnSRBv8w1lieNNGATNQ==", + "dependencies": { + "pg-connection-string": "^2.7.0", + "pg-pool": "^3.7.0", + "pg-protocol": "^1.7.0", + "pg-types": "^2.1.0", + "pgpass": "1.x" + }, + "engines": { + "node": ">= 8.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.1.1" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.1.1.tgz", + "integrity": "sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.7.0.tgz", + "integrity": "sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==" + }, + "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.7.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.7.0.tgz", + "integrity": "sha512-ZOBQForurqh4zZWjrgSwwAtzJ7QiRX0ovFkZr2klsen3Nm0aoh33Ls0fzfv3imeH/nw/O27cjdz5kzYJfeGp/g==", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.7.0.tgz", + "integrity": "sha512-hTK/mE36i8fDDhgDFjy6xNOG+LCorxLG3WO17tku+ij6sVHXh1jQUJ8hYAnRhNla4QVD2H8er/FOjc/+EgC6yQ==" + }, + "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.49.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.1.tgz", + "integrity": "sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==", + "dev": true, + "dependencies": { + "playwright-core": "1.49.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.49.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.1.tgz", + "integrity": "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==", + "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.6", + "resolved": "https://registry.npmjs.org/smtp-server/-/smtp-server-3.13.6.tgz", + "integrity": "sha512-dqbSPKn3PCq3Gp5hxBM99u7PET7cQSAWrauhtArJbc+zrf5xNEOjm9+Ob3lySySrRoIEvNE0dz+w2H/xWFJNRw==", + "dev": true, + "dependencies": { + "base32.js": "0.1.0", + "ipv6-normalize": "1.0.1", + "nodemailer": "6.9.15", + "punycode.js": "2.3.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/smtp-server/node_modules/nodemailer": { + "version": "6.9.15", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.15.tgz", + "integrity": "sha512-AHf04ySLC6CIfuRtRiEYtGEXgRfa6INgWGluDhnxTZhHSKvrBu7lc1VVchQ0d8nPc4cFaZoPq8vkyNoZr0TpGQ==", + "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.255.0", + "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.255.0.tgz", + "integrity": "sha512-tcwMRIioTcF/FcxLev8MJWxCp+GUALRhFEqbDoZrnowmKSGqPrl5pqS+Sut2m8BgJ6S4FExCSSpGffZ0Tks6Aw==", + "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.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.0.0.tgz", + "integrity": "sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==", + "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": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "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.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.1.0.tgz", + "integrity": "sha512-jlf/foYIKywAt3x/XWKZ/3rz8OSJPiWktjmk891alJUEjiVxKX9LEO92qH3hv4aJ0mN3MWPvGMCy8jQi95xK4w==", + "dev": true, + "dependencies": { + "tr46": "^5.0.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.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "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 0000000000..7bf09059f6 --- /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.49.1", + "dotenv": "^16.4.7", + "dotenv-expand": "^11.0.7", + "maildev": "github:timshel/maildev#3.0.2" + }, + "dependencies": { + "mysql2": "^3.12.0", + "otpauth": "^9.3.6", + "pg": "^8.13.1" + } +} diff --git a/playwright/playwright.config.ts b/playwright/playwright.config.ts new file mode 100644 index 0000000000..2d9a822325 --- /dev/null +++ b/playwright/playwright.config.ts @@ -0,0 +1,137 @@ +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, + + /* Retry on CI only */ + retries: 0, + workers: 1, + + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + timeout: 20 * 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', + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + viewport: { + width: 1920, + height: 1080 + }, + 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 0000000000..1faefb9028 --- /dev/null +++ b/playwright/test.env @@ -0,0 +1,89 @@ +################################################################## +### 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} +VAULTWARDEN_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} +SMTP_SECURITY=off +SMTP_PORT=${MAILDEV_SMTP_PORT} +SMTP_FROM_NAME=Vaultwarden +SMTP_TIMEOUT=5 + +SSO_CLIENT_ID=VaultWarden +SSO_CLIENT_SECRET=VaultWarden +SSO_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${TEST_REALM} + +########################### +# Docker MariaDb container# +########################### +MARIADB_PORT=3307 +MARIADB_ROOT_PASSWORD=vaultwarden +MARIADB_USER=vaultwarden +MARIADB_PASSWORD=vaultwarden +MARIADB_DATABASE=vaultwarden + +########################### +# Docker Mysql container# +########################### +MYSQL_PORT=3309 +MYSQL_ROOT_PASSWORD=vaultwarden +MYSQL_USER=vaultwarden +MYSQL_PASSWORD=vaultwarden +MYSQL_DATABASE=vaultwarden + +############################ +# Docker Postgres container# +############################ +POSTGRES_PORT=5433 +POSTGRES_USER=vaultwarden +POSTGRES_PASSWORD=vaultwarden +POSTGRES_DB=vaultwarden diff --git a/playwright/tests/login.smtp.spec.ts b/playwright/tests/login.smtp.spec.ts new file mode 100644 index 0000000000..8401b7c24f --- /dev/null +++ b/playwright/tests/login.smtp.spec.ts @@ -0,0 +1,163 @@ +import { test, expect, type TestInfo } from '@playwright/test'; +import { MailDev } from 'maildev'; + +const utils = require('../global-utils'); +import { createAccount, logUser } from './setups/user'; + +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.startVaultwarden(browser, testInfo, { + SMTP_HOST: process.env.MAILDEV_HOST, + SMTP_FROM: process.env.VAULTWARDEN_SMTP_FROM, + }); +}); + +test.afterAll('Teardown', async ({}) => { + utils.stopVaultwarden(); + if( mailserver ){ + await mailserver.close(); + } +}); + +test('Account creation', async ({ page }) => { + const emails = mailserver.iterator(users.user1.email); + + await createAccount(test, page, users.user1); + + const { value: created } = await emails.next(); + expect(created.subject).toBe("Welcome"); + expect(created.from[0]?.address).toBe(process.env.VAULTWARDEN_SMTP_FROM); + + // Back to the login page + await expect(page).toHaveTitle('Vaultwarden Web'); + await expect(page.getByTestId("toast-message")).toHaveText(/Your new account has been created/); + 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/); + + const { value: logged } = await emails.next(); + expect(logged.subject).toBe("New Device Logged In From Firefox"); + expect(logged.to[0]?.address).toBe(process.env.TEST_USER_MAIL); + expect(logged.from[0]?.address).toBe(process.env.VAULTWARDEN_SMTP_FROM); + + emails.return(); +}); + +test('Login', async ({ context, page }) => { + const emails = mailserver.iterator(users.user1.email); + + await logUser(test, page, users.user1); + + await test.step('new device email', async () => { + const { value: logged } = await emails.next(); + expect(logged.subject).toBe("New Device Logged In From Firefox"); + expect(logged.from[0]?.address).toBe(process.env.VAULTWARDEN_SMTP_FROM); + }); + + 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(); + + // Close the toast message + await expect(page.getByTestId("toast-message")).toHaveText(/Check your email inbox/); + await page.locator('#toast-container').getByRole('button').click(); + await expect(page.getByTestId("toast-message")).toHaveCount(0); + + const { value: verify } = await emails.next(); + expect(verify.subject).toBe("Verify Your Email"); + expect(verify.from[0]?.address).toBe(process.env.VAULTWARDEN_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 expect(page.getByTestId("toast-message")).toHaveText("Account email verified"); + }); + + emails.return(); +}); + +test('Activaite 2fa', async ({ context, page }) => { + const emails = mailserver.buffer(users.user1.email); + + await logUser(test, page, users.user1); + + await page.getByRole('button', { name: users.user1.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('li').filter({ hasText: 'Email' }).getByRole('button').click(); + await page.getByLabel('Master password (required)').fill(users.user1.password); + await page.getByRole('button', { name: 'Continue' }).click(); + await page.getByRole('button', { name: 'Send email' }).click(); + + const codeMail = await emails.next((mail) => mail.subject === "Vaultwarden Login Verification Code"); + const page2 = await context.newPage(); + await page2.setContent(codeMail.html); + const code = await page2.getByTestId("2fa").innerText(); + await page2.close(); + + 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 }); + + emails.close(); +}); + +test('2fa', async ({ context, 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(); + + const codeMail = await emails.next((mail) => mail.subject === "Vaultwarden Login Verification Code"); + const page2 = await context.newPage(); + await page2.setContent(codeMail.html); + const code = await page2.getByTestId("2fa").innerText(); + await page2.close(); + + await page.getByLabel('Verification code').fill(code); + await page.getByRole('button', { name: 'Continue' }).click(); + + await expect(page).toHaveTitle(/Vaultwarden Web/); + }) + + await test.step('disable', 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('li').filter({ hasText: 'Email' }).getByRole('button').click(); + await page.getByLabel('Master password (required)').click(); + await page.getByLabel('Master password (required)').fill(users.user1.password); + await page.getByRole('button', { name: 'Continue' }).click(); + await page.getByRole('button', { name: 'Turn off' }).click(); + await page.getByRole('button', { name: 'Yes' }).click(); + await expect(page.getByTestId("toast-message")).toHaveText(/Two-step login provider turned off/); + }); + + emails.close(); +}); diff --git a/playwright/tests/login.spec.ts b/playwright/tests/login.spec.ts new file mode 100644 index 0000000000..69309e2ec3 --- /dev/null +++ b/playwright/tests/login.spec.ts @@ -0,0 +1,94 @@ +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'; + +let users = utils.loadEnv(); +let totp; + +test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { + await utils.startVaultwarden(browser, testInfo, {}); +}); + +test.afterAll('Teardown', async ({}, testInfo: TestInfo) => { + utils.stopVaultwarden(testInfo); +}); + +test('Account creation', async ({ page }) => { + // Landing page + await createAccount(test, page, users.user1); + + 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('Master password login', async ({ page }) => { + await logUser(test, page, users.user1); +}); + +test('Authenticator 2fa', async ({ context, page }) => { + let totp; + + await test.step('Login', async () => { + await logUser(test, page, users.user1); + }); + + await test.step('Activate', async () => { + await page.getByRole('button', { name: users.user1.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('li').filter({ hasText: 'TOTP Authenticator' }).getByRole('button').click(); + await page.getByLabel('Master password (required)').fill(users.user1.password); + await page.getByRole('button', { name: 'Continue' }).click(); + + const secret = await page.getByLabel('Key').innerText(); + totp = new OTPAuth.TOTP({ secret, period: 30 }); + + await page.getByLabel('Verification code (required)').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(); + }) + + await test.step('logout', async () => { + await page.getByRole('button', { name: users.user1.name }).click(); + await page.getByRole('menuitem', { name: 'Log out' }).click(); + }); + + await test.step('login', async () => { + let timestamp = Date.now(); // Need 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 page.getByLabel('Verification code').fill(totp.generate({timestamp})); + await page.getByRole('button', { name: 'Continue' }).click(); + + await expect(page).toHaveTitle(/Vaultwarden Web/); + }); + + await test.step('disable', 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('li').filter({ hasText: 'TOTP Authenticator' }).getByRole('button').click(); + await page.getByLabel('Master password (required)').click(); + await page.getByLabel('Master password (required)').fill(users.user1.password); + await page.getByRole('button', { name: 'Continue' }).click(); + await page.getByRole('button', { name: 'Turn off' }).click(); + await page.getByRole('button', { name: 'Yes' }).click(); + await expect(page.getByTestId("toast-message")).toHaveText(/Two-step login provider turned off/); + }); +}); diff --git a/playwright/tests/organization.smtp.spec.ts b/playwright/tests/organization.smtp.spec.ts new file mode 100644 index 0000000000..6d5788c4cb --- /dev/null +++ b/playwright/tests/organization.smtp.spec.ts @@ -0,0 +1,170 @@ +import { test, expect, type TestInfo } from '@playwright/test'; +import { MailDev } from 'maildev'; + +import * as utils from "../global-utils"; +import { createAccount, logUser } from './setups/user'; + +let users = utils.loadEnv(); + +let mailserver, user1Mails, user2Mails, user3Mails; + +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.startVaultwarden(browser, testInfo, { + SMTP_HOST: process.env.MAILDEV_HOST, + SMTP_FROM: process.env.VAULTWARDEN_SMTP_FROM, + }); + + user1Mails = mailserver.iterator(users.user1.email); + user2Mails = mailserver.iterator(users.user2.email); + user3Mails = mailserver.iterator(users.user3.email); +}); + +test.afterAll('Teardown', async ({}, testInfo: TestInfo) => { + utils.stopVaultwarden(testInfo); + utils.closeMails(mailserver, [user1Mails, user2Mails, user3Mails]); +}); + +test('Create user3', async ({ page }) => { + await createAccount(test, page, users.user3, user3Mails); +}); + +test('Invite users', async ({ page }) => { + await createAccount(test, page, users.user1, user1Mails); + await logUser(test, page, users.user1, user1Mails); + + 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 test.step('Invite user2', async () => { + await page.getByRole('button', { name: 'Invite member' }).click(); + await page.getByLabel('Email (required)').fill(users.user2.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 expect(page.getByTestId("toast-message")).toHaveText('User(s) invited'); + await page.locator('#toast-container').getByRole('button').click(); + }); + + await test.step('Invite user3', async () => { + await page.getByRole('button', { name: 'Invite member' }).click(); + await page.getByLabel('Email (required)').fill(users.user3.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 expect(page.getByTestId("toast-message")).toHaveText('User(s) invited'); + await page.locator('#toast-container').getByRole('button').click(); + }); +}); + +test('invited with new account', async ({ page }) => { + const { value: invited } = await user2Mails.next(); + expect(invited.subject).toContain("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('Master password\n (required)', { exact: true }).fill(users.user2.password); + await page.getByLabel('Re-type master password').fill(users.user2.password); + await page.getByRole('button', { name: 'Create account' }).click(); + + // Back to the login page + await expect(page).toHaveTitle('Vaultwarden Web'); + await expect(page.getByTestId("toast-message")).toHaveText(/Your new account has been created/); + await page.locator('#toast-container').getByRole('button').click(); + + const { value: welcome } = await user2Mails.next(); + expect(welcome.subject).toContain("Welcome") + }); + + await test.step('Login', async () => { + await page.getByLabel(/Email address/).fill(users.user2.email); + await page.getByRole('button', { name: 'Continue' }).click(); + + // Unlock page + await page.getByLabel('Master password').fill(users.user2.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 expect(page.getByTestId("toast-title")).toHaveText("Invitation accepted"); + await page.locator('#toast-container').getByRole('button').click(); + + const { value: logged } = await user2Mails.next(); + expect(logged.subject).toContain("New Device Logged"); + }); + + const { value: accepted } = await user1Mails.next(); + expect(accepted.subject).toContain("Invitation to Test accepted") +}); + +test('invited with existing account', async ({ page }) => { + const { value: invited } = await user3Mails.next(); + expect(invited.subject).toContain("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 expect(page.getByTestId("toast-title")).toHaveText("Invitation accepted"); + await page.locator('#toast-container').getByRole('button').click(); + + const { value: logged } = await user3Mails.next(); + expect(logged.subject).toContain("New Device Logged") + + const { value: accepted } = await user1Mails.next(); + expect(accepted.subject).toContain("Invitation to Test accepted") +}); + +test('Confirm invited user', async ({ page }) => { + await logUser(test, page, users.user1, user1Mails); + await page.getByLabel('Switch products').click(); + await page.getByRole('link', { name: ' Admin Console' }).click(); + await page.getByRole('link', { name: 'Members' }).click(); + + await test.step('Accept user2', async () => { + await page.getByRole('row', { name: users.user2.name }).getByLabel('Options').click(); + await page.getByRole('menuitem', { name: 'Confirm' }).click(); + await page.getByRole('button', { name: 'Confirm' }).click(); + await expect(page.getByTestId("toast-message")).toHaveText(/confirmed/); + await page.locator('#toast-container').getByRole('button').click(); + + const { value: logged } = await user2Mails.next(); + expect(logged.subject).toContain("Invitation to Test confirmed"); + }); +}); + +test('Organization is visible', async ({ page }) => { + await logUser(test, page, users.user2, user2Mails); + await page.getByLabel('vault: Test').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 0000000000..0b05176077 --- /dev/null +++ b/playwright/tests/organization.spec.ts @@ -0,0 +1,94 @@ +import { test, expect, type TestInfo } from '@playwright/test'; +import { MailDev } from 'maildev'; + +import * as utils from "../global-utils"; +import { createAccount, logUser } from './setups/user'; + +let users = utils.loadEnv(); + +test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { + await utils.startVaultwarden(browser, testInfo); +}); + +test.afterAll('Teardown', async ({}, testInfo: TestInfo) => { + utils.stopVaultwarden(testInfo); +}); + +test('Create user3', async ({ page }) => { + await createAccount(test, page, users.user3); +}); + +test('Invite users', async ({ page }) => { + await createAccount(test, page, users.user1); + await logUser(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 test.step('Invite user2', async () => { + await page.getByRole('button', { name: 'Invite member' }).click(); + await page.getByLabel('Email (required)').fill(users.user2.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 expect(page.getByTestId("toast-message")).toHaveText('User(s) invited'); + await page.locator('#toast-container').getByRole('button').click(); + await expect(page.getByRole('row', { name: users.user2.email })).toHaveText(/Invited/); + }); + + await test.step('Invite user3', async () => { + await page.getByRole('button', { name: 'Invite member' }).click(); + await page.getByLabel('Email (required)').fill(users.user3.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 expect(page.getByTestId("toast-message")).toHaveText('User(s) invited'); + await page.locator('#toast-container').getByRole('button').click(); + await expect(page.getByRole('row', { name: users.user3.name })).toHaveText(/Needs confirmation/); + }); + + await test.step('Confirm existing user3', async () => { + await page.getByRole('row', { name: users.user3.name }).getByLabel('Options').click(); + await page.getByRole('menuitem', { name: 'Confirm' }).click(); + await page.getByRole('button', { name: 'Confirm' }).click(); + await expect(page.getByTestId("toast-message")).toHaveText(/confirmed/); + await page.locator('#toast-container').getByRole('button').click(); + }); +}); + +test('Create invited account', async ({ page }) => { + await createAccount(test, page, users.user2); +}); + +test('Confirm invited user', async ({ page }) => { + await logUser(test, page, users.user1); + await page.getByLabel('Switch products').click(); + await page.getByRole('link', { name: ' Admin Console' }).click(); + await page.getByRole('link', { name: 'Members' }).click(); + + await test.step('Confirm user2', async () => { + await page.getByRole('row', { name: users.user2.name }).getByLabel('Options').click(); + await page.getByRole('menuitem', { name: 'Confirm' }).click(); + await page.getByRole('button', { name: 'Confirm' }).click(); + await expect(page.getByTestId("toast-message")).toHaveText(/confirmed/); + }); +}); + +test('Organization is visible', async ({ context, page }) => { + await logUser(test, page, users.user2); + await page.getByLabel('vault: Test').click(); + await expect(page.getByLabel('Filter: Default collection')).toBeVisible(); + + const page2 = await context.newPage(); + await logUser(test, page2, users.user3); + await page2.getByLabel('vault: Test').click(); + await expect(page2.getByLabel('Filter: Default collection')).toBeVisible(); +}); diff --git a/playwright/tests/setups/db-setup.ts b/playwright/tests/setups/db-setup.ts new file mode 100644 index 0000000000..eb37fdc102 --- /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 0000000000..5f753a9d2c --- /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 0000000000..4a72d37c57 --- /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/sso-setup.ts b/playwright/tests/setups/sso-setup.ts new file mode 100644 index 0000000000..0d25140ef6 --- /dev/null +++ b/playwright/tests/setups/sso-setup.ts @@ -0,0 +1,19 @@ +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 }) => { + test.setTimeout(60000); + 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 0000000000..2899afff14 --- /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 0000000000..dc176b0e84 --- /dev/null +++ b/playwright/tests/setups/sso.ts @@ -0,0 +1,111 @@ +import { expect, type Page, Test } from '@playwright/test'; +import { type MailBuffer, MailServer } from 'maildev'; + +/** + * 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, mailServer?: MailServer } = {} +) { + let mailBuffer = options.mailBuffer ?? options.mailServer?.buffer(user.email); + try { + await test.step('Create user', async () => { + await test.step('Landing page', async () => { + await page.goto('/'); + await page.getByLabel(/Email address/).fill(user.email); + await page.getByRole('button', 'Continue').click(); + }); + + await test.step('SSo start page', async () => { + await page.getByRole('link', { name: /Enterprise 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.getByText('Set master password')).toBeVisible(); + await page.getByLabel('Master password', { exact: true }).fill(user.password); + await page.getByLabel('Re-type master password').fill(user.password); + await page.getByRole('button', { name: 'Submit' }).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 emails', async () => { + await expect(mailBuffer.next((m) => m.subject.includes("New Device Logged"))).resolves.toBeDefined(); + await expect(mailBuffer.next((m) => m.subject === "Master Password Has Been Changed")).resolves.toBeDefined(); + }); + } + }); + } finally { + if( options.mailServer ){ + mailBuffer.close(); + } + } +} + +/** + * 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, mailServer?: MailServer} = {} +) { + let mailBuffer = options.mailBuffer ?? options.mailServer?.buffer(user.email); + try { + await test.step('Log user', async () => { + await test.step('Landing page', async () => { + await page.goto('/'); + await page.getByLabel(/Email address/).fill(user.email); + await page.getByRole('button', 'Continue').click(); + }); + + await test.step('SSo start page', async () => { + await page.getByRole('link', { name: /Enterprise 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('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( options.emails ){ + await test.step('Check email', async () => { + await expect(mailBuffer.next((m) => m.subject.includes("New Device Logged"))).resolves.toBeDefined(); + }); + } + }); + } finally { + if( options.mailServer ){ + mailBuffer.close(); + } + } +} diff --git a/playwright/tests/setups/user.ts b/playwright/tests/setups/user.ts new file mode 100644 index 0000000000..b91dc13396 --- /dev/null +++ b/playwright/tests/setups/user.ts @@ -0,0 +1,47 @@ +import { expect, type Browser,Page } from '@playwright/test'; + +export async function createAccount(test, page: Page, user: { email: string, name: string, password: string }, emails) { + await test.step('Create user', async () => { + // Landing page + await page.goto('/'); + 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.getByLabel('Master password\n (required)', { exact: true }).fill(user.password); + await page.getByLabel('Re-type master password').fill(user.password); + await page.getByRole('button', { name: 'Create account' }).click(); + + // Back to the login page + await expect(page).toHaveTitle('Vaultwarden Web'); + await expect(page.getByTestId("toast-message")).toHaveText(/Your new account has been created/); + + if( emails ){ + const { value: welcome } = await emails.next(); + expect(welcome.subject).toContain("Welcome"); + } + }); +} + +export async function logUser(test, page: Page, user: { email: string, password: string }, emails) { + await test.step('Log user', async () => { + // Landing page + await page.goto('/'); + 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( emails ){ + const { value: logged } = await emails.next(); + expect(logged.subject).toContain("New Device Logged"); + } + }); +} diff --git a/playwright/tests/sso_login.spec.ts b/playwright/tests/sso_login.spec.ts new file mode 100644 index 0000000000..efbe8c6899 --- /dev/null +++ b/playwright/tests/sso_login.spec.ts @@ -0,0 +1,77 @@ +import { test, expect, type TestInfo } from '@playwright/test'; +import { logNewUser, logUser } from './setups/sso'; +import * as utils from "../global-utils"; + +let users = utils.loadEnv(); + +test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { + await utils.startVaultwarden(browser, testInfo, { + SSO_ENABLED: true, + SSO_ONLY: false + }); +}); + +test.afterAll('Teardown', async ({}) => { + utils.stopVaultwarden(); +}); + +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('Non SSO login impossible', async ({ page, browser }, testInfo: TestInfo) => { + await utils.restartVaultwarden(page, testInfo, { + SSO_ENABLED: true, + SSO_ONLY: true + }, false); + + // 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(); + + // An error should appear + await page.getByLabel('SSO sign-in is required') + + // Check the selector for the next test + await expect(page.getByRole('link', { name: /Enterprise single sign-on/ })).toHaveCount(1); +}); + + +test('No SSO login', async ({ page }, testInfo: TestInfo) => { + await utils.restartVaultwarden(page, testInfo, { + SSO_ENABLED: false + }, false); + + // Landing page + await page.goto('/'); + await page.getByLabel(/Email address/).fill(users.user1.email); + await page.getByRole('button', { name: 'Continue' }).click(); + + // No SSO button (rely on a correct selector checked in previous test) + await page.getByLabel('Master password'); + await expect(page.getByRole('link', { name: /Enterprise single sign-on/ })).toHaveCount(0); +}); diff --git a/playwright/tests/sso_organization.smtp.spec.ts b/playwright/tests/sso_organization.smtp.spec.ts new file mode 100644 index 0000000000..e64d6d670b --- /dev/null +++ b/playwright/tests/sso_organization.smtp.spec.ts @@ -0,0 +1,146 @@ +import { test, expect, type TestInfo } from '@playwright/test'; +import { MailDev } from 'maildev'; + +import * as utils from "../global-utils"; +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.startVaultwarden(browser, testInfo, { + SMTP_HOST: process.env.MAILDEV_HOST, + SMTP_FROM: process.env.VAULTWARDEN_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.stopVaultwarden(); + [mailServer, mail1Buffer, mail2Buffer, mail3Buffer].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 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 test.step('Invite user2', async () => { + await page.getByRole('button', { name: 'Invite member' }).click(); + await page.getByLabel('Email (required)').fill(users.user2.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 expect(page.getByTestId("toast-message")).toHaveText('User(s) invited'); + await page.locator('#toast-container').getByRole('button').click(); + }); + + await test.step('Invite user3', async () => { + await page.getByRole('button', { name: 'Invite member' }).click(); + await page.getByLabel('Email (required)').fill(users.user3.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 expect(page.getByTestId("toast-message")).toHaveText('User(s) invited'); + await page.locator('#toast-container').getByRole('button').click(); + }); +}); + +test.fail('invited with new account', async ({ page }) => { + const link = await test.step('Extract email link', async () => { + const invited = await mail2Buffer.next((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.getByText('Set master password')).toBeVisible(); + await page.getByLabel('Master password', { exact: true }).fill(users.user2.password); + await page.getByLabel('Re-type master password').fill(users.user2.password); + await page.getByRole('button', { name: 'Submit' }).click(); + }); + + await test.step('Default vault page', async () => { + await expect(page).toHaveTitle(/Vaultwarden Web/); + await expect(page.getByTestId("toast-title")).toHaveText("Invitation accepted"); + await page.locator('#toast-container').getByRole('button').click(); + }); + + await test.step('Check mails', async () => { + await expect(mail2Buffer.next((m) => m.subject.includes("New Device Logged"))).resolves.toBeDefined(); + await expect(mail1Buffer.next((m) => m.subject === "Invitation to Test accepted")).resolves.toBeDefined(); + }); +}); + +test('invited with existing account', async ({ page }) => { + const link = await test.step('Extract email link', async () => { + const invited = await mail3Buffer.next((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 expect(page.getByTestId("toast-title")).toHaveText("Invitation accepted"); + await page.locator('#toast-container').getByRole('button').click(); + }); + + await test.step('Check mails', async () => { + await expect(mail3Buffer.next((m) => m.subject.includes("New Device Logged"))).resolves.toBeDefined(); + await expect(mail1Buffer.next((m) => m.subject === "Invitation to Test accepted")).resolves.toBeDefined(); + }); +}); diff --git a/playwright/tests/sso_organization.spec.ts b/playwright/tests/sso_organization.spec.ts new file mode 100644 index 0000000000..f9e73c0dc5 --- /dev/null +++ b/playwright/tests/sso_organization.spec.ts @@ -0,0 +1,94 @@ +import { test, expect, type TestInfo } from '@playwright/test'; +import { MailDev } from 'maildev'; + +import * as utils from "../global-utils"; +import { logNewUser, logUser } from './setups/sso'; + +let users = utils.loadEnv(); + +test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { + await utils.startVaultwarden(browser, testInfo, { + SSO_ENABLED: true, + SSO_ONLY: true, + }); +}); + +test.afterAll('Teardown', async ({}) => { + utils.stopVaultwarden(); +}); + +test('Create user3', async ({ page }) => { + await logNewUser(test, page, users.user3); +}); + +test('Invite users', async ({ page }) => { + await logNewUser(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 test.step('Invite user2', async () => { + await page.getByRole('button', { name: 'Invite member' }).click(); + await page.getByLabel('Email (required)').fill(users.user2.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 expect(page.getByTestId("toast-message")).toHaveText('User(s) invited'); + await page.locator('#toast-container').getByRole('button').click(); + await expect(page.getByRole('row', { name: users.user2.email })).toHaveText(/Invited/); + }); + + await test.step('Invite user3', async () => { + await page.getByRole('button', { name: 'Invite member' }).click(); + await page.getByLabel('Email (required)').fill(users.user3.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 expect(page.getByTestId("toast-message")).toHaveText('User(s) invited'); + await page.locator('#toast-container').getByRole('button').click(); + await expect(page.getByRole('row', { name: users.user3.name })).toHaveText(/Needs confirmation/); + }); + + await test.step('Confirm existing user3', async () => { + await page.getByRole('row', { name: users.user3.name }).getByLabel('Options').click(); + await page.getByRole('menuitem', { name: 'Confirm' }).click(); + await page.getByRole('button', { name: 'Confirm' }).click(); + await expect(page.getByTestId("toast-message")).toHaveText(/confirmed/); + await page.locator('#toast-container').getByRole('button').click(); + }); +}); + +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 page.getByLabel('Switch products').click(); + await page.getByRole('link', { name: ' Admin Console' }).click(); + await page.getByRole('link', { name: 'Members' }).click(); + + await expect(page.getByRole('row', { name: users.user2.name })).toHaveText(/Needs confirmation/); + + await test.step('Confirm user2', async () => { + await page.getByRole('row', { name: users.user2.name }).getByLabel('Options').click(); + await page.getByRole('menuitem', { name: 'Confirm' }).click(); + await page.getByRole('button', { name: 'Confirm' }).click(); + await expect(page.getByTestId("toast-message")).toHaveText(/confirmed/); + await page.locator('#toast-container').getByRole('button').click(); + }); +}); + +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(); +}); diff --git a/src/api/admin.rs b/src/api/admin.rs index b3e703d94d..e8f762eba7 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -296,7 +296,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() { diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index 6b4c4ac5b2..491f6c6acf 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -8,8 +8,8 @@ use serde_json::Value; use crate::{ api::{ core::{log_user_event, two_factor::email}, - register_push_device, unregister_push_device, AnonymousNotify, EmptyResult, JsonResult, Notify, - PasswordOrOtpData, UpdateType, + 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, @@ -84,6 +85,21 @@ pub struct RegisterData { organization_user_id: 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, + #[allow(dead_code)] + org_identifier: Option, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct KeysData { @@ -163,10 +179,7 @@ pub async fn _register(data: Json, mut conn: DbConn) -> JsonResult 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() @@ -182,7 +195,7 @@ pub async fn _register(data: Json, mut conn: DbConn) -> JsonResult // because the vaultwarden admin can invite anyone, regardless // of other signup restrictions. if Invitation::take(&email, &mut conn).await || CONFIG.is_signup_allowed(&email) { - User::new(email.clone()) + User::new(email.clone(), None) } else { err!("Registration not allowed or user already exists") } @@ -246,6 +259,61 @@ pub async fn _register(data: Json, mut conn: DbConn) -> JsonResult }))) } +#[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 CONFIG.mail_enabled() { + mail::send_set_password(&user.email.to_lowercase(), &user.name).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) @@ -982,16 +1050,35 @@ 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(()) +} + +// It appears that at the moment the return policy is required but ignored. +// As such the `enforceOnLogin` part is not working. #[post("/accounts/verify-password", data = "")] -fn verify_password(data: Json, headers: Headers) -> EmptyResult { +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") } - Ok(()) + kdf_upgrade(&mut user, &data.master_password_hash, &mut conn).await?; + + Ok(Json(json!({ + "MasterPasswordPolicy": master_password_policy(&user, &conn).await, + }))) } async fn _api_key(data: Json, rotate: bool, headers: Headers, mut conn: DbConn) -> JsonResult { diff --git a/src/api/core/emergency_access.rs b/src/api/core/emergency_access.rs index 8c6fcb6538..ec1a11b2de 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 172bca4266..b42387259b 100644 --- a/src/api/core/mod.rs +++ b/src/api/core/mod.rs @@ -218,7 +218,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, diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index aabcc5e258..54455ba91b 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -16,7 +16,7 @@ use crate::{ }, db::{models::*, DbConn}, mail, - util::{convert_json_key_lcase_first, NumberOrString}, + util::{convert_json_key_lcase_first, get_uuid, NumberOrString}, CONFIG, }; @@ -46,6 +46,7 @@ pub fn routes() -> Vec { bulk_delete_organization_collections, post_bulk_collections, get_org_details, + get_org_domain_sso_details, get_members, send_invite, reinvite_member, @@ -63,6 +64,7 @@ pub fn routes() -> Vec { post_org_import, list_policies, list_policies_token, + get_master_password_policy, get_policy, put_policy, get_organization_tax, @@ -106,6 +108,7 @@ pub fn routes() -> Vec { api_key, rotate_api_key, get_billing_metadata, + get_auto_enroll_status, ] } @@ -338,6 +341,17 @@ async fn get_user_collections(headers: Headers, mut conn: DbConn) -> Json })) } +// Called during the SSO enrollment +// The `_identifier` should be the harcoded 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/<_identifier>/auto-enroll-status")] +fn get_auto_enroll_status(_identifier: &str) -> JsonResult { + Ok(Json(json!({ + "Id": get_uuid(), + "ResetPasswordEnabled": false, // Not implemented + }))) +} + #[get("/organizations//collections")] async fn get_org_collections(org_id: OrganizationId, headers: ManagerHeadersLoose, mut conn: DbConn) -> JsonResult { if org_id != headers.membership.org_uuid { @@ -918,6 +932,18 @@ async fn _get_org_details(org_id: &OrganizationId, host: &str, user_id: &UserId, json!(ciphers_json) } +// Endpoint called when the user select SSO login (body: `{ "email": "" }`). +// Returning a Domain/Organization here allow to prefill it and prevent prompting the user +// VaultWarden sso login is not linked to Org so we set a dummy value. +#[post("/organizations/domain/sso/details")] +fn get_org_domain_sso_details() -> JsonResult { + Ok(Json(json!({ + "organizationIdentifier": "vaultwarden", + "ssoAvailable": CONFIG.sso_enabled(), + "verifiedDate": crate::util::format_date(&chrono::Utc::now().naive_utc()), + }))) +} + #[derive(FromForm)] struct GetOrgUserData { #[field(name = "includeCollections")] @@ -1055,7 +1081,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 @@ -1985,18 +2011,34 @@ async fn list_policies_token(org_id: OrganizationId, token: &str, mut conn: DbCo }))) } -#[get("/organizations//policies/")] +// Called during the SSO enrollment. +// Cannot use the OrganizationId guard since the Org does not exists. +#[get("/organizations//policies/master-password", rank = 1)] +fn get_master_password_policy(org_id: OrganizationId, _headers: Headers) -> JsonResult { + let data = match CONFIG.sso_master_password_policy() { + Some(policy) => policy, + None => "null".to_string(), + }; + + let policy = + 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())) @@ -2107,7 +2149,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; diff --git a/src/api/core/public.rs b/src/api/core/public.rs index 1c85ae1b9a..9cdd594f38 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() { diff --git a/src/api/identity.rs b/src/api/identity.rs index 38cdfce583..5ca185a904 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,21 +12,25 @@ 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}, }, + master_password_policy, push::register_push_device, ApiResult, EmptyResult, JsonResult, }, - auth::{generate_organization_api_key_login_claims, ClientHeaders, ClientIp}, + auth, + auth::{AuthMethod, ClientHeaders, ClientIp}, db::{models::*, DbConn}, error::MapResult, - mail, util, CONFIG, + mail, sso, + sso::{OIDCCode, OIDCState}, + util, CONFIG, }; pub fn routes() -> Vec { - routes![login, prelogin, identity_register] + routes![login, prelogin, identity_register, _prevalidate, prevalidate, authorize, oidcsignin, oidcsignin_error] } #[post("/connect/token", data = "")] @@ -38,6 +44,7 @@ async fn login(data: Form, client_header: ClientHeaders, mut conn: _check_is_some(&data.refresh_token, "refresh_token cannot be blank")?; _refresh_login(data, &mut conn).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")?; @@ -61,6 +68,17 @@ async fn login(data: Form, client_header: ClientHeaders, mut conn: _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).await + } + "authorization_code" => err!("SSO sign-in is not available"), t => err!("Invalid type", t), }; @@ -94,49 +112,186 @@ async fn login(data: Form, client_header: ClientHeaders, mut conn: login_result } +// Return Status::Unauthorized to trigger logout async fn _refresh_login(data: ConnectData, conn: &mut DbConn) -> 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); - device.save(conn).await?; + match auth::refresh_tokens(&refresh_token, 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 + 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) -> 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, new_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, new_device) = get_device(&data, conn, &user).await?; -#[derive(Default, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -struct MasterPasswordPolicy { - min_complexity: u8, - min_length: u32, - require_lower: bool, - require_upper: bool, - require_numbers: bool, - require_special: bool, - enforce_on_login: bool, + (user, device, new_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, new_device) = get_device(&data, conn, &user).await?; + let twofactor_token = twofactor_auth(&user, &data, &mut device, ip, 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, new_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, + auth_user.refresh_token, + &auth_user.access_token, + auth_user.expires_in, + )?; + + authenticated_response(&user, &mut device, new_device, auth_tokens, twofactor_token, &now, conn, ip).await } async fn _password_login( @@ -146,11 +301,7 @@ async fn _password_login( ip: &ClientIp, ) -> 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)?; @@ -217,13 +368,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(); @@ -260,12 +406,28 @@ async fn _password_login( ) } - let (mut device, new_device) = get_device(&data, conn, &user).await; + let (mut device, new_device) = get_device(&data, conn, &user).await?; let twofactor_token = twofactor_auth(&user, &data, &mut device, ip, conn).await?; + let auth_tokens = auth::AuthTokens::new(&device, &user, AuthMethod::Password); + + authenticated_response(&user, &mut device, new_device, auth_tokens, twofactor_token, &now, conn, ip).await +} + +#[allow(clippy::too_many_arguments)] +async fn authenticated_response( + user: &User, + device: &mut Device, + new_device: bool, + auth_tokens: auth::AuthTokens, + twofactor_token: Option, + now: &NaiveDateTime, + conn: &mut DbConn, + ip: &ClientIp, +) -> JsonResult { 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 { + 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() { @@ -281,78 +443,43 @@ async fn _password_login( // register push device if !new_device { - register_push_device(&mut device, conn).await?; + 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); + // Save to update `device.updated_at` to track usage device.save(conn).await?; - // Fetch all valid Master Password Policies and merge them into one with all true's and larges numbers as one policy - let master_password_policies: Vec = - OrgPolicy::find_accepted_and_confirmed_by_user_and_active_policy( - &user.uuid, - OrgPolicyType::MasterPassword, - conn, - ) - .await - .into_iter() - .filter_map(|p| serde_json::from_str(&p.data).ok()) - .collect(); - - let master_password_policy = if !master_password_policies.is_empty() { - let mut mpp_json = json!(master_password_policies.into_iter().reduce(|acc, policy| { - MasterPasswordPolicy { - min_complexity: acc.min_complexity.max(policy.min_complexity), - min_length: acc.min_length.max(policy.min_length), - require_lower: acc.require_lower || policy.require_lower, - require_upper: acc.require_upper || policy.require_upper, - require_numbers: acc.require_numbers || policy.require_numbers, - require_special: acc.require_special || policy.require_special, - enforce_on_login: acc.enforce_on_login || policy.enforce_on_login, - } - })); - mpp_json["object"] = json!("masterPasswordPolicy"); - mpp_json - } else { - json!({"object": "masterPasswordPolicy"}) - }; + let mp_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, "KdfParallelism": user.client_kdf_parallelism, "ResetMasterPassword": false, // TODO: Same as above "ForcePasswordReset": false, - "MasterPasswordPolicy": master_password_policy, - - "scope": scope, + "MasterPasswordPolicy": mp_policy, + "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 {} logged in successfully. IP: {}", username, ip.ip); + info!("User {} logged in successfully. IP: {}", user.email, ip.ip); Ok(Json(result)) } @@ -366,9 +493,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"), } } @@ -415,7 +542,7 @@ async fn _user_api_key_login( ) } - let (mut device, new_device) = get_device(&data, conn, &user).await; + let (mut device, new_device) = get_device(&data, conn, &user).await?; if CONFIG.mail_enabled() && new_device { let now = Utc::now().naive_utc(); @@ -433,15 +560,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); + // let orgs = Membership::find_confirmed_by_user(&user.uuid, conn).await; + let access_claims = auth::LoginJwtClaims::default(&device, &user, &AuthMethod::UserApiKey); + + // Save to update `device.updated_at` to track usage device.save(conn).await?; info!("User {} logged in successfully via API key. IP: {}", user.email, ip.ip); @@ -449,8 +576,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, @@ -460,7 +587,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)) @@ -483,19 +610,19 @@ async fn _organization_api_key_login(data: ConnectData, conn: &mut DbConn, ip: & err!("Incorrect client_secret", format!("IP: {}. Organization: {}.", ip.ip, org_api_key.org_uuid)) } - 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 claim = auth::generate_organization_api_key_login_claims(org_api_key.uuid, org_api_key.org_uuid); + 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<(Device, bool)> { // 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); @@ -507,12 +634,13 @@ async fn get_device(data: &ConnectData, conn: &mut DbConn, user: &User) -> (Devi let device = match Device::find_by_uuid_and_user(&device_id, &user.uuid, conn).await { Some(device) => device, None => { + let device = Device::new(device_id, user.uuid.clone(), device_name, device_type); new_device = true; - Device::new(device_id, user.uuid.clone(), device_name, device_type) + device } }; - (device, new_device) + Ok((device, new_device)) } async fn twofactor_auth( @@ -537,9 +665,7 @@ async fn twofactor_auth( let twofactor_code = match data.two_factor_token { Some(ref code) => code, - None => { - err_json!(_json_err_twofactor(&twofactor_ids, &user.uuid, data, conn).await?, "2FA token not provided") - } + None => err_json!(_json_err_twofactor(&twofactor_ids, &user.uuid, data, conn).await?, "2FA token not provided"), }; let selected_twofactor = twofactors.into_iter().find(|tf| tf.atype == selected_id && tf.enabled); @@ -601,12 +727,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 { @@ -770,11 +897,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 27a3775fbf..aa3dd87b10 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -32,10 +32,14 @@ pub use crate::api::{ web::routes as web_routes, web::static_files, }; -use crate::db::{models::User, DbConn}; +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<()>; @@ -68,3 +72,50 @@ impl PasswordOrOtpData { Ok(()) } } + +#[derive(Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MasterPasswordPolicy { + min_complexity: u8, + min_length: u32, + require_lower: bool, + require_upper: bool, + require_numbers: bool, + require_special: bool, + enforce_on_login: bool, +} + +// Fetch all valid Master Password Policies and merge them into one with all true's and larges numbers as one policy +async fn master_password_policy(user: &User, conn: &DbConn) -> Value { + let master_password_policies: Vec = + OrgPolicy::find_accepted_and_confirmed_by_user_and_active_policy( + &user.uuid, + OrgPolicyType::MasterPassword, + conn, + ) + .await + .into_iter() + .filter_map(|p| serde_json::from_str(&p.data).ok()) + .collect(); + + let mut mpp_json = if !master_password_policies.is_empty() { + json!(master_password_policies.into_iter().reduce(|acc, policy| { + MasterPasswordPolicy { + min_complexity: acc.min_complexity.max(policy.min_complexity), + min_length: acc.min_length.max(policy.min_length), + require_lower: acc.require_lower || policy.require_lower, + require_upper: acc.require_upper || policy.require_upper, + require_numbers: acc.require_numbers || policy.require_numbers, + require_special: acc.require_special || policy.require_special, + 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!({}) + }; + + mpp_json["object"] = json!("masterPasswordPolicy"); + mpp_json +} diff --git a/src/api/web.rs b/src/api/web.rs index ebb0b0e0c6..a866bc1f54 100644 --- a/src/api/web.rs +++ b/src/api/web.rs @@ -55,12 +55,14 @@ 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(), - "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_enabled": CONFIG.mail_enabled(), + "sends_allowed": CONFIG.sends_allowed(), + "signup_disabled": CONFIG.is_signup_disabled(), + "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 cfb7c30be5..d7a5af35ed 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}; @@ -14,15 +13,23 @@ use std::{ net::IpAddr, }; -use crate::db::models::{ - AttachmentId, CipherId, CollectionId, DeviceId, EmergencyAccessId, MembershipId, OrgApiKeyId, OrganizationId, - SendFileId, SendId, UserId, +use crate::{ + api::ApiResult, + db::models::{ + AttachmentId, CipherId, CollectionId, DeviceId, 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 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())); @@ -90,7 +97,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; @@ -109,6 +116,10 @@ fn decode_jwt(token: &str, issuer: String) -> Result Result { + decode_jwt(token, JWT_LOGIN_ISSUER.to_string()) +} + pub fn decode_login(token: &str) -> Result { decode_jwt(token, JWT_LOGIN_ISSUER.to_string()) } @@ -182,6 +193,73 @@ pub struct LoginJwtClaims { pub amr: Vec, } +impl LoginJwtClaims { + pub fn new(device: &Device, user: &User, nbf: i64, exp: i64, scope: Vec, 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(), + scope, + amr: vec!["Application".into()], + } + } + + pub fn default(device: &Device, user: &User, auth_method: &AuthMethod) -> Self { + let time_now = Utc::now(); + Self::new( + device, + user, + time_now.timestamp(), + (time_now + *DEFAULT_ACCESS_VALIDITY).timestamp(), + auth_method.scope_vec(), + 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 @@ -966,3 +1044,143 @@ 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) -> Self { + let time_now = Utc::now(); + + let access_claims = LoginJwtClaims::default(device, user, &sub); + + let refresh_claims = RefreshJwtClaims { + nbf: time_now.timestamp(), + exp: (time_now + *DEFAULT_REFRESH_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(refresh_token: &str, conn: &mut DbConn) -> ApiResult<(Device, AuthTokens)> { + let time_now = Utc::now(); + + let refresh_claims = match decode_refresh(refresh_token) { + Err(err) => 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, + }; + + if refresh_claims.exp < time_now.timestamp() { + err!("Expired refresh token"); + } + + 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) + } + AuthMethod::Sso if CONFIG.sso_enabled() => sso::exchange_refresh_token(&device, &user, &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), + _ => err!("Invalid auth method, cannot refresh token"), + }; + + Ok((device, auth_tokens)) +} diff --git a/src/config.rs b/src/config.rs index 09e6ac37b7..cc7716163d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -435,6 +435,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 @@ -652,6 +655,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 @@ -878,6 +917,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") @@ -1055,6 +1105,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) { @@ -1086,6 +1165,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 { @@ -1128,6 +1211,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() +} + impl Config { pub fn load() -> Result { // Loading from env and file @@ -1204,6 +1307,14 @@ impl Config { self.update_config(builder, false) } + // 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). @@ -1222,12 +1333,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. @@ -1325,6 +1431,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::{ @@ -1387,7 +1509,9 @@ where reg!("email/send_emergency_access_invite", ".html"); reg!("email/send_org_invite", ".html"); reg!("email/send_single_org_removed_from_org", ".html"); + reg!("email/set_password", ".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"); @@ -1486,3 +1610,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 74ef46d2c1..3b4b501f34 100644 --- a/src/db/models/device.rs +++ b/src/db/models/device.rs @@ -1,9 +1,11 @@ use chrono::{NaiveDateTime, Utc}; + +use data_encoding::{BASE64, BASE64URL}; use derive_more::{Display, From}; use serde_json::Value; use super::{AuthRequest, UserId}; -use crate::{crypto, util::format_date, CONFIG}; +use crate::{crypto, util::format_date}; use macros::IdFromParam; db_object! { @@ -44,7 +46,7 @@ impl Device { push_uuid: None, push_token: None, - refresh_token: String::new(), + refresh_token: crypto::encode_random_bytes::<64>(BASE64URL), twofactor_remember: None, } } @@ -62,7 +64,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()); @@ -73,61 +74,6 @@ impl Device { self.twofactor_remember = None; } - pub fn refresh_tokens(&mut self, user: &super::User, scope: Vec) -> (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(); - - // --- - // 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(), - scope, - amr: vec!["Application".into()], - }; - - (encode_jwt(&claims), DEFAULT_VALIDITY.num_seconds()) - } - pub fn is_push_device(&self) -> bool { matches!(DeviceType::from_i32(self.atype), DeviceType::Android | DeviceType::Ios) } diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index 90d17313b7..b426d8f754 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 304b37422a..643f968b93 100644 --- a/src/db/models/org_policy.rs +++ b/src/db/models/org_policy.rs @@ -63,12 +63,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, } } @@ -78,12 +78,11 @@ impl OrgPolicy { } pub fn to_json(&self) -> Value { - let data_json: Value = serde_json::from_str(&self.data).unwrap_or(Value::Null); json!({ "id": self.uuid, "organizationId": self.org_uuid, "type": self.atype, - "data": data_json, + "data": serde_json::from_str(&self.data).unwrap_or(Value::Null), "enabled": self.enabled, "object": "policy", }) @@ -197,7 +196,7 @@ impl OrgPolicy { pub async fn find_accepted_and_confirmed_by_user_and_active_policy( user_uuid: &UserId, policy_type: OrgPolicyType, - conn: &mut DbConn, + conn: &DbConn, ) -> Vec { db_run! { conn: { org_policies::table diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index 2b54e1d0f7..28c12fa557 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -822,6 +822,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 diff --git a/src/db/models/sso_nonce.rs b/src/db/models/sso_nonce.rs new file mode 100644 index 0000000000..2246a43741 --- /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 8978fc5adf..50c2638500 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -10,13 +10,14 @@ use crate::{ crypto, 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 +72,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 +105,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 +117,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, @@ -478,3 +487,49 @@ 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()) }) + }} + } +} diff --git a/src/db/schemas/mysql/schema.rs b/src/db/schemas/mysql/schema.rs index 573e4503b3..a8d4a76af4 100644 --- a/src/db/schemas/mysql/schema.rs +++ b/src/db/schemas/mysql/schema.rs @@ -254,6 +254,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 +365,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 +379,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 a3707adf3a..1ee20c6f1e 100644 --- a/src/db/schemas/postgresql/schema.rs +++ b/src/db/schemas/postgresql/schema.rs @@ -254,6 +254,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 +365,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 +379,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 a3707adf3a..1ee20c6f1e 100644 --- a/src/db/schemas/sqlite/schema.rs +++ b/src/db/schemas/sqlite/schema.rs @@ -254,6 +254,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 +365,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 +379,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 1061a08dc6..d0048b0c78 100644 --- a/src/error.rs +++ b/src/error.rs @@ -147,6 +147,10 @@ impl Error { pub fn get_event(&self) -> &Option { &self.event } + + pub fn message(&self) -> &str { + &self.message + } } pub trait MapResult { @@ -251,9 +255,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 d074995a97..03613cfa0e 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -280,7 +280,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"); } } @@ -549,6 +553,30 @@ pub async fn send_change_email(address: &str, token: &str) -> EmptyResult { 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_set_password(address: &str, user_name: &str) -> EmptyResult { + let (subject, body_html, body_text) = get_text( + "email/set_password", + json!({ + "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), + "user_name": user_name, + }), + )?; + 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 530c7b2cd0..9efda57c8b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -56,6 +56,7 @@ mod db; mod http_client; mod mail; mod ratelimit; +mod sso; mod util; use crate::api::core::two_factor::duo_oidc::purge_duo_contexts; @@ -699,6 +700,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 0000000000..19531b27f4 --- /dev/null +++ b/src/sso.rs @@ -0,0 +1,630 @@ +use chrono::Utc; +use derive_more::{AsRef, Deref, Display, From}; +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::{ + CoreClient, CoreIdTokenVerifier, CoreProviderMetadata, CoreResponseType, CoreUserInfoClaims, +}; +use openidconnect::reqwest::async_http_client; +use openidconnect::{ + AccessToken, AuthDisplay, AuthPrompt, AuthenticationFlow, AuthorizationCode, AuthorizationRequest, ClientId, + ClientSecret, CsrfToken, Nonce, OAuth2TokenResponse, PkceCodeChallenge, PkceCodeVerifier, RefreshToken, + ResponseType, Scope, +}; + +use crate::{ + api::ApiResult, + auth, + auth::{AuthMethod, AuthTokens, TokenWrapper, BW_EXPIRATION, DEFAULT_REFRESH_VALIDITY}, + db::{ + models::{Device, SsoNonce, User}, + DbConn, + }, + CONFIG, +}; + +static AC_CACHE: Lazy> = + Lazy::new(|| Cache::builder().max_capacity(1000).time_to_live(Duration::from_secs(10 * 60)).build()); + +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() +}); + +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()); + +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 + } +} + +#[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}")), + } +} + +#[rocket::async_trait] +trait CoreClientExt { + async fn _get_client() -> ApiResult; + async fn cached() -> ApiResult; + + async fn user_info_async(&self, access_token: AccessToken) -> ApiResult; + + fn vw_id_token_verifier(&self) -> CoreIdTokenVerifier<'_>; +} + +#[rocket::async_trait] +impl CoreClientExt for CoreClient { + // 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 provider_metadata = match CoreProviderMetadata::discover_async(issuer_url, async_http_client).await { + Err(err) => err!(format!("Failed to discover OpenID provider: {err}")), + Ok(metadata) => metadata, + }; + + Ok(CoreClient::from_provider_metadata(provider_metadata, client_id, Some(client_secret)) + .set_redirect_uri(CONFIG.sso_redirect_url()?)) + } + + // Simple cache to prevent recalling the discovery endpoint each time + 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 + } + } + + async fn user_info_async(&self, access_token: AccessToken) -> ApiResult { + let endpoint = match self.user_info(access_token, None) { + Err(err) => err!(format!("No user_info endpoint: {err}")), + Ok(endpoint) => endpoint, + }; + + match endpoint.request_async(async_http_client).await { + Err(err) => err!(format!("Request to user_info endpoint failed: {err}")), + Ok(user_info) => Ok(user_info), + } + } + + fn vw_id_token_verifier(&self) -> CoreIdTokenVerifier<'_> { + let mut verifier = self.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 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 +// The `state` is encoded using base64 to ensure no issue with providers (It contains the Organization identifier). +// 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 scopes = CONFIG.sso_scopes_vec().into_iter().map(Scope::new); + let base64_state = data_encoding::BASE64.encode(state.to_string().as_bytes()); + + 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 client = CoreClient::cached().await?; + let mut auth_req = 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.secret().to_string()) + } else { + None + }; + + let (auth_url, _, nonce) = auth_req.url(); + + let sso_nonce = SsoNonce::new(state, nonce.secret().to_string(), verifier, redirect_uri); + sso_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 { + 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 oidc_code = AuthorizationCode::new(code.to_string()); + let client = CoreClient::cached().await?; + + let nonce = match SsoNonce::find(&state, conn).await { + None => err!(format!("Invalid state cannot retrieve nonce")), + Some(nonce) => nonce, + }; + + let mut exchange = 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)), + } + } + + match exchange.request_async(async_http_client).await { + Ok(token_response) => { + let user_info = client.user_info_async(token_response.access_token().to_owned()).await?; + let oidc_nonce = Nonce::new(nonce.nonce.clone()); + + 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().to_string()); + debug!("Refresh token: {:?}", token_response.refresh_token().map(|t| t.secret().to_string())); + debug!("Expiration time: {:?}", token_response.expires_in()); + } + + let id_claims = match id_token.claims(&client.vw_id_token_verifier(), &oidc_nonce) { + Ok(claims) => claims, + Err(err) => { + if CONFIG.sso_client_cache_expiration() > 0 { + CLIENT_CACHE.invalidate(&*CLIENT_CACHE_KEY); + } + err!(format!("Could not read id_token claims, {err}")); + } + }; + + let email = match id_claims.email() { + Some(email) => email.to_string(), + None => match user_info.email() { + None => err!("Neither id token nor userinfo contained an email"), + Some(email) => email.to_owned().to_string(), + }, + } + .to_lowercase(); + + let user_name = user_info.preferred_username().map(|un| un.to_string()); + + let refresh_token = token_response.refresh_token().map(|t| t.secret().to_string()); + 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, + access_token: token_response.access_token().secret().to_string(), + expires_in: token_response.expires_in(), + identifier: identifier.clone(), + email: email.clone(), + email_verified: id_claims.email_verified(), + user_name: user_name.clone(), + }; + + AC_CACHE.insert(state.clone(), authenticated_user.clone()); + + Ok(UserInformation { + state, + identifier, + email, + email_verified: id_claims.email_verified(), + user_name, + }) + } + Err(err) => err!(format!("Failed to contact token endpoint: {err}")), + } +} + +// 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, + refresh_token: Option, + access_token: &str, + 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(), now); + + _create_auth_tokens(device, refresh_token, access_claims, access_token) + } else { + Ok(AuthTokens::new(device, user, AuthMethod::Sso)) + } +} + +fn _create_auth_tokens( + device: &Device, + refresh_token: Option, + access_claims: auth::LoginJwtClaims, + access_token: &str, +) -> ApiResult { + let (nbf, exp, token) = if let Some(rt) = refresh_token.as_ref() { + 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.to_string())) + } + Ok(refresh_payload) => { + debug!("Refresh_payload: {:?}", refresh_payload); + (refresh_payload.nbf(), refresh_payload.exp, TokenWrapper::Refresh(rt.to_string())) + } + } + } else { + debug!("No refresh_token present"); + (access_claims.nbf, access_claims.exp, TokenWrapper::Access(access_token.to_string())) + }; + + 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, + refresh_claims: &auth::RefreshJwtClaims, +) -> ApiResult { + match &refresh_claims.token { + Some(TokenWrapper::Refresh(refresh_token)) => { + let rt = RefreshToken::new(refresh_token.to_string()); + + let client = CoreClient::cached().await?; + + let token_response = match client.exchange_refresh_token(&rt).request_async(async_http_client).await { + Err(err) => err!(format!("Request to exchange_refresh_token endpoint failed: {:?}", err)), + Ok(token_response) => token_response, + }; + + // Use new refresh_token if returned + let rolled_refresh_token = token_response + .refresh_token() + .map(|token| token.secret().to_string()) + .unwrap_or(refresh_token.to_string()); + + create_auth_tokens( + device, + user, + Some(rolled_refresh_token), + token_response.access_token().secret(), + token_response.expires_in(), + ) + } + Some(TokenWrapper::Access(access_token)) => { + let now = Utc::now(); + let exp_limit = (now + *BW_EXPIRATION).timestamp(); + + if refresh_claims.exp < exp_limit { + err_silent!("Access token is close to expiration but we have no refresh token") + } + + let client = CoreClient::cached().await?; + match client.user_info_async(AccessToken::new(access_token.to_string())).await { + Err(err) => { + err_silent!(format!("Failed to retrieve user info, token has probably been invalidated: {err}")) + } + Ok(_) => { + let access_claims = auth::LoginJwtClaims::new( + device, + user, + now.timestamp(), + refresh_claims.exp, + AuthMethod::Sso.scope_vec(), + now, + ); + _create_auth_tokens(device, None, access_claims, access_token) + } + } + } + None => err!("No token present while in SSO"), + } +} diff --git a/src/static/templates/email/send_org_invite.html.hbs b/src/static/templates/email/send_org_invite.html.hbs index ce3a6c050a..8fc6ccf65e 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/set_password.hbs b/src/static/templates/email/set_password.hbs new file mode 100644 index 0000000000..923c80f2ea --- /dev/null +++ b/src/static/templates/email/set_password.hbs @@ -0,0 +1,6 @@ +Master Password Has Been Changed + +The master password for {{user_name}} has been changed. If you did not initiate this request, please reach out to your administrator immediately. + +=== +{{> email/email_footer_text }} \ No newline at end of file diff --git a/src/static/templates/email/set_password.html.hbs b/src/static/templates/email/set_password.html.hbs new file mode 100644 index 0000000000..ede5da0cc3 --- /dev/null +++ b/src/static/templates/email/set_password.html.hbs @@ -0,0 +1,11 @@ +Master Password Has Been Changed + +{{> email/email_header }} + + + + +
+ The master password for {{user_name}} has been changed. If you did not initiate this request, please reach out to your administrator immediately. +
+{{> email/email_footer }} \ No newline at end of file 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 0000000000..5a512280c9 --- /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 0000000000..74cd445cd6 --- /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 30990d9e0c..672daa3257 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 c37cf36dad..29a1377fe2 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 42c4d8dce7..0ea820e959 100644 --- a/src/static/templates/scss/vaultwarden.scss.hbs +++ b/src/static/templates/scss/vaultwarden.scss.hbs @@ -20,11 +20,6 @@ a[href$="/settings/sponsored-families"] { @extend %vw-hide; } -/* Hide the `Enterprise Single Sign-On` button on the login page */ -a[routerlink="/sso"] { - @extend %vw-hide; -} - /* Hide Two-Factor menu in Organization settings */ bit-nav-item[route="settings/two-factor"], a[href$="/settings/two-factor"] { @@ -100,6 +95,20 @@ app-login form div + div + div + div + hr + p { } {{/if}} +{{#if sso_only}} +/* Hide Master password login */ +.master-password-login { + @extend %vw-hide; +} +{{/if}} + +{{#if sso_disabled}} +/* Hide the `Enterprise Single Sign-On` button on the login page */ +a[routerlink="/sso"] { + @extend %vw-hide; +} +{{/if}} + {{#unless mail_enabled}} /* Hide `Email` 2FA if mail is not enabled */ app-two-factor-setup ul.list-group.list-group-2fa li.list-group-item:nth-child(1) { diff --git a/src/util.rs b/src/util.rs index 1f8d1c27d6..9f00a8e6b5 100644 --- a/src/util.rs +++ b/src/util.rs @@ -142,9 +142,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