Browse Source

Merge branch 'main' into update_webauthn_rs

pull/5934/head
Daniel García 5 days ago
parent
commit
943cf330f6
No known key found for this signature in database GPG Key ID: FC8A7D14C3CD543A
  1. 53
      .env.template
  2. 572
      Cargo.lock
  3. 8
      Cargo.toml
  4. 1
      migrations/mysql/2023-09-10-133000_add_sso/down.sql
  5. 4
      migrations/mysql/2023-09-10-133000_add_sso/up.sql
  6. 1
      migrations/mysql/2023-09-14-133000_add_users_organizations_invited_by_email/down.sql
  7. 1
      migrations/mysql/2023-09-14-133000_add_users_organizations_invited_by_email/up.sql
  8. 6
      migrations/mysql/2024-02-14-170000_add_state_to_sso_nonce/down.sql
  9. 8
      migrations/mysql/2024-02-14-170000_add_state_to_sso_nonce/up.sql
  10. 8
      migrations/mysql/2024-02-26-170000_add_pkce_to_sso_nonce/down.sql
  11. 9
      migrations/mysql/2024-02-26-170000_add_pkce_to_sso_nonce/up.sql
  12. 1
      migrations/mysql/2024-03-06-170000_add_sso_users/down.sql
  13. 7
      migrations/mysql/2024-03-06-170000_add_sso_users/up.sql
  14. 0
      migrations/mysql/2024-03-13-170000_sso_users_cascade/down.sql
  15. 2
      migrations/mysql/2024-03-13-170000_sso_users_cascade/up.sql
  16. 1
      migrations/postgresql/2023-09-10-133000_add_sso/down.sql
  17. 4
      migrations/postgresql/2023-09-10-133000_add_sso/up.sql
  18. 1
      migrations/postgresql/2023-09-14-133000_add_users_organizations_invited_by_email/down.sql
  19. 1
      migrations/postgresql/2023-09-14-133000_add_users_organizations_invited_by_email/up.sql
  20. 6
      migrations/postgresql/2024-02-14-170000_add_state_to_sso_nonce/down.sql
  21. 8
      migrations/postgresql/2024-02-14-170000_add_state_to_sso_nonce/up.sql
  22. 8
      migrations/postgresql/2024-02-26-170000_add_pkce_to_sso_nonce/down.sql
  23. 9
      migrations/postgresql/2024-02-26-170000_add_pkce_to_sso_nonce/up.sql
  24. 1
      migrations/postgresql/2024-03-06-170000_add_sso_users/down.sql
  25. 7
      migrations/postgresql/2024-03-06-170000_add_sso_users/up.sql
  26. 0
      migrations/postgresql/2024-03-13-170000_sso_users_cascade/down.sql
  27. 3
      migrations/postgresql/2024-03-13-170000_sso_users_cascade/up.sql
  28. 1
      migrations/sqlite/2023-09-10-133000_add_sso/down.sql
  29. 4
      migrations/sqlite/2023-09-10-133000_add_sso/up.sql
  30. 1
      migrations/sqlite/2023-09-14-133000_add_users_organizations_invited_by_email/down.sql
  31. 1
      migrations/sqlite/2023-09-14-133000_add_users_organizations_invited_by_email/up.sql
  32. 6
      migrations/sqlite/2024-02-14-170000_add_state_to_sso_nonce/down.sql
  33. 8
      migrations/sqlite/2024-02-14-170000_add_state_to_sso_nonce/up.sql
  34. 8
      migrations/sqlite/2024-02-26-170000_add_pkce_to_sso_nonce/down.sql
  35. 9
      migrations/sqlite/2024-02-26-170000_add_pkce_to_sso_nonce/up.sql
  36. 1
      migrations/sqlite/2024-03-06-170000_add_sso_users/down.sql
  37. 7
      migrations/sqlite/2024-03-06-170000_add_sso_users/up.sql
  38. 0
      migrations/sqlite/2024-03-13_170000_sso_userscascade/down.sql
  39. 9
      migrations/sqlite/2024-03-13_170000_sso_userscascade/up.sql
  40. 64
      playwright/.env.template
  41. 6
      playwright/.gitignore
  42. 166
      playwright/README.md
  43. 40
      playwright/compose/keycloak/Dockerfile
  44. 36
      playwright/compose/keycloak/setup.sh
  45. 40
      playwright/compose/playwright/Dockerfile
  46. 40
      playwright/compose/warden/Dockerfile
  47. 24
      playwright/compose/warden/build.sh
  48. 124
      playwright/docker-compose.yml
  49. 22
      playwright/global-setup.ts
  50. 246
      playwright/global-utils.ts
  51. 2547
      playwright/package-lock.json
  52. 21
      playwright/package.json
  53. 143
      playwright/playwright.config.ts
  54. 93
      playwright/test.env
  55. 37
      playwright/tests/collection.spec.ts
  56. 100
      playwright/tests/login.smtp.spec.ts
  57. 51
      playwright/tests/login.spec.ts
  58. 115
      playwright/tests/organization.smtp.spec.ts
  59. 54
      playwright/tests/organization.spec.ts
  60. 92
      playwright/tests/setups/2fa.ts
  61. 7
      playwright/tests/setups/db-setup.ts
  62. 11
      playwright/tests/setups/db-teardown.ts
  63. 9
      playwright/tests/setups/db-test.ts
  64. 77
      playwright/tests/setups/orgs.ts
  65. 18
      playwright/tests/setups/sso-setup.ts
  66. 15
      playwright/tests/setups/sso-teardown.ts
  67. 138
      playwright/tests/setups/sso.ts
  68. 55
      playwright/tests/setups/user.ts
  69. 53
      playwright/tests/sso_login.smtp.spec.ts
  70. 94
      playwright/tests/sso_login.spec.ts
  71. 121
      playwright/tests/sso_organization.smtp.spec.ts
  72. 76
      playwright/tests/sso_organization.spec.ts
  73. 33
      src/api/admin.rs
  74. 165
      src/api/core/accounts.rs
  75. 99
      src/api/core/ciphers.rs
  76. 2
      src/api/core/emergency_access.rs
  77. 51
      src/api/core/mod.rs
  78. 173
      src/api/core/organizations.rs
  79. 14
      src/api/core/public.rs
  80. 21
      src/api/core/two_factor/email.rs
  81. 487
      src/api/identity.rs
  82. 5
      src/api/mod.rs
  83. 12
      src/api/notifications.rs
  84. 12
      src/api/web.rs
  85. 254
      src/auth.rs
  86. 102
      src/config.rs
  87. 132
      src/db/models/device.rs
  88. 2
      src/db/models/event.rs
  89. 4
      src/db/models/mod.rs
  90. 4
      src/db/models/org_policy.rs
  91. 71
      src/db/models/organization.rs
  92. 89
      src/db/models/sso_nonce.rs
  93. 93
      src/db/models/user.rs
  94. 20
      src/db/schemas/mysql/schema.rs
  95. 20
      src/db/schemas/postgresql/schema.rs
  96. 20
      src/db/schemas/sqlite/schema.rs
  97. 10
      src/error.rs
  98. 18
      src/mail.rs
  99. 9
      src/main.rs
  100. 462
      src/sso.rs

53
.env.template

@ -174,6 +174,10 @@
## Cron schedule of the job that cleans expired Duo contexts from the database. Does nothing if Duo MFA is disabled or set to use the legacy iframe prompt.
## Defaults to every minute. Set blank to disable this job.
# DUO_CONTEXT_PURGE_SCHEDULE="30 * * * * *"
#
## Cron schedule of the job that cleans sso nonce from incomplete flow
## Defaults to daily (20 minutes after midnight). Set blank to disable this job.
# PURGE_INCOMPLETE_SSO_NONCE="0 20 0 * * *"
########################
### General settings ###
@ -459,6 +463,55 @@
## 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"
## Additional 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 for additional trusted Id token audience (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 ###
########################

572
Cargo.lock

@ -694,6 +694,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.21.7"
@ -825,6 +831,12 @@ version = "3.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
[[package]]
name = "bytecount"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e"
[[package]]
name = "bytemuck"
version = "1.23.1"
@ -889,6 +901,37 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0"
[[package]]
name = "camino"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0da45bc31171d8d6960122e222a67740df867c1dd53b4d51caa297084c185cab"
dependencies = [
"serde",
]
[[package]]
name = "cargo-platform"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea"
dependencies = [
"serde",
]
[[package]]
name = "cargo_metadata"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4acbb09d9ee8e23699b9634375c72795d095bf268439da88562cf9b501f181fa"
dependencies = [
"camino",
"cargo-platform",
"semver",
"serde",
"serde_json",
]
[[package]]
name = "cbc"
version = "0.1.2"
@ -1136,6 +1179,18 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-bigint"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
dependencies = [
"generic-array",
"rand_core 0.6.4",
"subtle",
"zeroize",
]
[[package]]
name = "crypto-common"
version = "0.1.6"
@ -1146,6 +1201,33 @@ dependencies = [
"typenum",
]
[[package]]
name = "curve25519-dalek"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "373b7c5dbd637569a2cca66e8d66b8c446a1e7bf064ea321d265d7b3dfe7c97e"
dependencies = [
"cfg-if",
"cpufeatures",
"curve25519-dalek-derive",
"digest",
"fiat-crypto",
"rustc_version",
"subtle",
"zeroize",
]
[[package]]
name = "curve25519-dalek-derive"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "darling"
version = "0.20.11"
@ -1181,6 +1263,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"
@ -1239,6 +1334,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e"
dependencies = [
"powerfmt",
"serde",
]
[[package]]
@ -1465,12 +1561,77 @@ dependencies = [
"syn",
]
[[package]]
name = "dyn-clone"
version = "1.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005"
[[package]]
name = "ecdsa"
version = "0.16.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
dependencies = [
"der",
"digest",
"elliptic-curve",
"rfc6979",
"signature",
"spki",
]
[[package]]
name = "ed25519"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
"pkcs8",
"signature",
]
[[package]]
name = "ed25519-dalek"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
"curve25519-dalek",
"ed25519",
"serde",
"sha2",
"subtle",
"zeroize",
]
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "elliptic-curve"
version = "0.13.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
dependencies = [
"base16ct",
"crypto-bigint",
"digest",
"ff",
"generic-array",
"group",
"hkdf",
"pem-rfc7468",
"pkcs8",
"rand_core 0.6.4",
"sec1",
"subtle",
"zeroize",
]
[[package]]
name = "email-encoding"
version = "0.4.1"
@ -1533,6 +1694,15 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "error-chain"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d2f06b9cac1506ece98fe3231e3cc9c4410ec3d5b1f24ae1c8946f0742cdefc"
dependencies = [
"version_check",
]
[[package]]
name = "event-listener"
version = "2.5.3"
@ -1578,6 +1748,22 @@ dependencies = [
"syslog",
]
[[package]]
name = "ff"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
dependencies = [
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "fiat-crypto"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24"
[[package]]
name = "figment"
version = "0.10.19"
@ -1781,6 +1967,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
"zeroize",
]
[[package]]
@ -1841,7 +2028,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3cbe789d04bf14543f03c4b60cd494148aa79438c8440ae7d81a7778147745c3"
dependencies = [
"cfg-if",
"dashmap",
"dashmap 6.1.0",
"futures-sink",
"futures-timer",
"futures-util",
@ -1864,12 +2051,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d9e3df7f0222ce5184154973d247c591d9aadc28ce7a73c6cd31100c9facff6"
dependencies = [
"codemap",
"indexmap",
"indexmap 2.10.0",
"lasso",
"once_cell",
"phf 0.11.3",
]
[[package]]
name = "group"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
dependencies = [
"ff",
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "h2"
version = "0.4.11"
@ -1882,7 +2080,7 @@ dependencies = [
"futures-core",
"futures-sink",
"http 1.3.1",
"indexmap",
"indexmap 2.10.0",
"slab",
"tokio",
"tokio-util",
@ -1912,6 +2110,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"
@ -1997,6 +2201,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"
@ -2164,6 +2377,22 @@ dependencies = [
"webpki-roots",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper 1.6.0",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.16"
@ -2327,6 +2556,17 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "indexmap"
version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
dependencies = [
"autocfg",
"hashbrown 0.12.3",
"serde",
]
[[package]]
name = "indexmap"
version = "2.10.0"
@ -2404,6 +2644,15 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "itertools"
version = "0.10.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.15"
@ -2694,6 +2943,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"
@ -2769,6 +3033,23 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "native-tls"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework 2.11.1",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "nom"
version = "7.1.3"
@ -2912,6 +3193,26 @@ dependencies = [
"libc",
]
[[package]]
name = "oauth2"
version = "5.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"
dependencies = [
"base64 0.22.1",
"chrono",
"getrandom 0.2.16",
"http 1.3.1",
"rand 0.8.5",
"reqwest",
"serde",
"serde_json",
"serde_path_to_error",
"sha2",
"thiserror 1.0.69",
"url",
]
[[package]]
name = "object"
version = "0.36.7"
@ -2968,6 +3269,37 @@ dependencies = [
"uuid",
]
[[package]]
name = "openidconnect"
version = "4.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d8c6709ba2ea764bbed26bce1adf3c10517113ddea6f2d4196e4851757ef2b2"
dependencies = [
"base64 0.21.7",
"chrono",
"dyn-clone",
"ed25519-dalek",
"hmac",
"http 1.3.1",
"itertools",
"log",
"oauth2",
"p256",
"p384",
"rand 0.8.5",
"rsa",
"serde",
"serde-value",
"serde_json",
"serde_path_to_error",
"serde_plain",
"serde_with",
"sha2",
"subtle",
"thiserror 1.0.69",
"url",
]
[[package]]
name = "openssl"
version = "0.10.73"
@ -3022,6 +3354,15 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "ordered-float"
version = "2.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c"
dependencies = [
"num-traits",
]
[[package]]
name = "ordered-multimap"
version = "0.7.3"
@ -3044,6 +3385,30 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
[[package]]
name = "p256"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
dependencies = [
"ecdsa",
"elliptic-curve",
"primeorder",
"sha2",
]
[[package]]
name = "p384"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6"
dependencies = [
"ecdsa",
"elliptic-curve",
"primeorder",
"sha2",
]
[[package]]
name = "parking"
version = "2.2.1"
@ -3385,6 +3750,15 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "primeorder"
version = "0.13.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
dependencies = [
"elliptic-curve",
]
[[package]]
name = "proc-macro2"
version = "1.0.95"
@ -3432,6 +3806,17 @@ dependencies = [
"psl-types",
]
[[package]]
name = "pulldown-cmark"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57206b407293d2bcd3af849ce869d52068623f19e1b5ff8e8778e3309439682b"
dependencies = [
"bitflags",
"memchr",
"unicase",
]
[[package]]
name = "quanta"
version = "0.12.6"
@ -3762,10 +4147,12 @@ dependencies = [
"http-body-util",
"hyper 1.6.0",
"hyper-rustls",
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"mime",
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
@ -3777,6 +4164,7 @@ dependencies = [
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-rustls 0.26.2",
"tokio-util",
"tower",
@ -3796,6 +4184,16 @@ version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95325155c684b1c89f7765e30bc1c42e4a6da51ca513615660cb8a62ef9a88e3"
[[package]]
name = "rfc6979"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
dependencies = [
"hmac",
"subtle",
]
[[package]]
name = "ring"
version = "0.17.14"
@ -3845,7 +4243,7 @@ dependencies = [
"either",
"figment",
"futures",
"indexmap",
"indexmap 2.10.0",
"log",
"memchr",
"multer",
@ -3877,7 +4275,7 @@ checksum = "575d32d7ec1a9770108c879fc7c47815a80073f96ca07ff9525a94fcede1dd46"
dependencies = [
"devise",
"glob",
"indexmap",
"indexmap 2.10.0",
"proc-macro2",
"quote",
"rocket_http",
@ -3897,7 +4295,7 @@ dependencies = [
"futures",
"http 0.2.12",
"hyper 0.14.32",
"indexmap",
"indexmap 2.10.0",
"log",
"memchr",
"pear",
@ -4057,7 +4455,7 @@ dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
"security-framework 3.2.0",
]
[[package]]
@ -4148,6 +4546,30 @@ dependencies = [
"parking_lot",
]
[[package]]
name = "schemars"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
dependencies = [
"dyn-clone",
"ref-cast",
"serde",
"serde_json",
]
[[package]]
name = "schemars"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0"
dependencies = [
"dyn-clone",
"ref-cast",
"serde",
"serde_json",
]
[[package]]
name = "scoped-tls"
version = "1.0.1"
@ -4181,6 +4603,33 @@ dependencies = [
"untrusted",
]
[[package]]
name = "sec1"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
dependencies = [
"base16ct",
"der",
"generic-array",
"pkcs8",
"subtle",
"zeroize",
]
[[package]]
name = "security-framework"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags",
"core-foundation 0.9.4",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework"
version = "3.2.0"
@ -4209,6 +4658,9 @@ name = "semver"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0"
dependencies = [
"serde",
]
[[package]]
name = "serde"
@ -4219,6 +4671,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_2"
version = "0.12.0-dev"
@ -4252,6 +4714,25 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_path_to_error"
version = "0.1.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a"
dependencies = [
"itoa",
"serde",
]
[[package]]
name = "serde_plain"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50"
dependencies = [
"serde",
]
[[package]]
name = "serde_spanned"
version = "0.6.9"
@ -4282,6 +4763,38 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_with"
version = "3.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2c45cd61fefa9db6f254525d46e392b852e0e61d9a1fd36e5bd183450a556d5"
dependencies = [
"base64 0.22.1",
"chrono",
"hex",
"indexmap 1.9.3",
"indexmap 2.10.0",
"schemars 0.9.0",
"schemars 1.0.4",
"serde",
"serde_derive",
"serde_json",
"serde_with_macros",
"time",
]
[[package]]
name = "serde_with_macros"
version = "3.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de90945e6565ce0d9a25098082ed4ee4002e047cb59892c318d66821e14bb30f"
dependencies = [
"darling",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "sha1"
version = "0.10.6"
@ -4366,6 +4879,21 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d"
[[package]]
name = "skeptic"
version = "0.13.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16d23b015676c90a0f01c197bfdc786c20342c73a0afdda9025adb0bc42940a8"
dependencies = [
"bytecount",
"cargo_metadata",
"error-chain",
"glob",
"pulldown-cmark",
"tempfile",
"walkdir",
]
[[package]]
name = "slab"
version = "0.4.10"
@ -4725,6 +5253,16 @@ dependencies = [
"syn",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.24.1"
@ -4800,7 +5338,7 @@ version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed0aee96c12fa71097902e0bb061a5e1ebd766a6636bb605ba401c45c1650eac"
dependencies = [
"indexmap",
"indexmap 2.10.0",
"serde",
"serde_spanned 1.0.0",
"toml_datetime 0.7.0",
@ -4833,7 +5371,7 @@ version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap",
"indexmap 2.10.0",
"serde",
"serde_spanned 0.6.9",
"toml_datetime 0.6.11",
@ -4981,6 +5519,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"
@ -5037,6 +5581,12 @@ dependencies = [
"version_check",
]
[[package]]
name = "unicase"
version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"
[[package]]
name = "unicode-ident"
version = "1.0.18"
@ -5125,7 +5675,7 @@ dependencies = [
"chrono-tz",
"cookie",
"cookie_store",
"dashmap",
"dashmap 6.1.0",
"data-encoding",
"data-url",
"derive_more",
@ -5150,10 +5700,12 @@ dependencies = [
"log",
"macros",
"mimalloc",
"mini-moka",
"num-derive",
"num-traits",
"once_cell",
"opendal",
"openidconnect",
"openssl",
"pastey",
"percent-encoding",

8
Cargo.toml

@ -34,6 +34,10 @@ enable_mimalloc = ["dep:mimalloc"]
query_logger = ["dep:diesel_logger"]
s3 = ["opendal/services-s3", "dep:aws-config", "dep:aws-credential-types", "dep:aws-smithy-runtime-api", "dep:anyhow", "dep:http", "dep:reqsign"]
# OIDC specific features
oidc-accept-rfc3339-timestamps = ["openidconnect/accept-rfc3339-timestamps"]
oidc-accept-string-booleans = ["openidconnect/accept-string-booleans"]
# Enable unstable features, requires nightly
# Currently only used to enable rusts official ip support
unstable = []
@ -166,6 +170,10 @@ pico-args = "0.5.0"
pastey = "0.1.0"
governor = "0.10.0"
# OIDC for SSO
openidconnect = { version = "4.0.1", features = ["reqwest", "native-tls"] }
mini-moka = "0.10.2"
# Check client versions for specific features.
semver = "1.0.26"

1
migrations/mysql/2023-09-10-133000_add_sso/down.sql

@ -0,0 +1 @@
DROP TABLE sso_nonce;

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

1
migrations/mysql/2023-09-14-133000_add_users_organizations_invited_by_email/down.sql

@ -0,0 +1 @@
ALTER TABLE users_organizations DROP COLUMN invited_by_email;

1
migrations/mysql/2023-09-14-133000_add_users_organizations_invited_by_email/up.sql

@ -0,0 +1 @@
ALTER TABLE users_organizations ADD COLUMN invited_by_email TEXT DEFAULT NULL;

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

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

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

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

1
migrations/mysql/2024-03-06-170000_add_sso_users/down.sql

@ -0,0 +1 @@
DROP TABLE IF EXISTS sso_users;

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

0
migrations/mysql/2024-03-13-170000_sso_users_cascade/down.sql

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

1
migrations/postgresql/2023-09-10-133000_add_sso/down.sql

@ -0,0 +1 @@
DROP TABLE sso_nonce;

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

1
migrations/postgresql/2023-09-14-133000_add_users_organizations_invited_by_email/down.sql

@ -0,0 +1 @@
ALTER TABLE users_organizations DROP COLUMN invited_by_email;

1
migrations/postgresql/2023-09-14-133000_add_users_organizations_invited_by_email/up.sql

@ -0,0 +1 @@
ALTER TABLE users_organizations ADD COLUMN invited_by_email TEXT DEFAULT NULL;

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

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

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

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

1
migrations/postgresql/2024-03-06-170000_add_sso_users/down.sql

@ -0,0 +1 @@
DROP TABLE IF EXISTS sso_users;

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

0
migrations/postgresql/2024-03-13-170000_sso_users_cascade/down.sql

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

1
migrations/sqlite/2023-09-10-133000_add_sso/down.sql

@ -0,0 +1 @@
DROP TABLE sso_nonce;

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

1
migrations/sqlite/2023-09-14-133000_add_users_organizations_invited_by_email/down.sql

@ -0,0 +1 @@
ALTER TABLE users_organizations DROP COLUMN invited_by_email;

1
migrations/sqlite/2023-09-14-133000_add_users_organizations_invited_by_email/up.sql

@ -0,0 +1 @@
ALTER TABLE users_organizations ADD COLUMN invited_by_email TEXT DEFAULT NULL;

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

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

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

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

1
migrations/sqlite/2024-03-06-170000_add_sso_users/down.sql

@ -0,0 +1 @@
DROP TABLE IF EXISTS sso_users;

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

0
migrations/sqlite/2024-03-13_170000_sso_userscascade/down.sql

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

64
playwright/.env.template

@ -0,0 +1,64 @@
#################################
### Conf to run dev instances ###
#################################
ENV=dev
DC_ENV_FILE=.env
COMPOSE_IGNORE_ORPHANS=True
DOCKER_BUILDKIT=1
################
# Users Config #
################
TEST_USER=test
TEST_USER_PASSWORD=${TEST_USER}
TEST_USER_MAIL=${TEST_USER}@yopmail.com
TEST_USER2=test2
TEST_USER2_PASSWORD=${TEST_USER2}
TEST_USER2_MAIL=${TEST_USER2}@yopmail.com
TEST_USER3=test3
TEST_USER3_PASSWORD=${TEST_USER3}
TEST_USER3_MAIL=${TEST_USER3}@yopmail.com
###################
# Keycloak Config #
###################
KEYCLOAK_ADMIN=admin
KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN}
KC_HTTP_HOST=127.0.0.1
KC_HTTP_PORT=8080
# Script parameters (use Keycloak and Vaultwarden config too)
TEST_REALM=test
DUMMY_REALM=dummy
DUMMY_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${DUMMY_REALM}
######################
# Vaultwarden Config #
######################
ROCKET_ADDRESS=0.0.0.0
ROCKET_PORT=8000
DOMAIN=http://127.0.0.1:${ROCKET_PORT}
LOG_LEVEL=info,oidcwarden::sso=debug
I_REALLY_WANT_VOLATILE_STORAGE=true
SSO_ENABLED=true
SSO_ONLY=false
SSO_CLIENT_ID=warden
SSO_CLIENT_SECRET=warden
SSO_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${TEST_REALM}
SMTP_HOST=127.0.0.1
SMTP_PORT=1025
SMTP_SECURITY=off
SMTP_TIMEOUT=5
SMTP_FROM=vaultwarden@test
SMTP_FROM_NAME=Vaultwarden
########################################################
# DUMMY values for docker-compose to stop bothering us #
########################################################
MARIADB_PORT=3305
MYSQL_PORT=3307
POSTGRES_PORT=5432

6
playwright/.gitignore

@ -0,0 +1,6 @@
logs
node_modules/
/test-results/
/playwright-report/
/playwright/.cache/
temp

166
playwright/README.md

@ -0,0 +1,166 @@
# Integration tests
This allows running integration tests using [Playwright](https://playwright.dev/).
\
It usse its own [test.env](/test/scenarios/test.env) with different ports to not collide with a running dev instance.
## Install
This rely on `docker` and the `compose` [plugin](https://docs.docker.com/compose/install/).
Databases (`Mariadb`, `Mysql` and `Postgres`) and `Playwright` will run in containers.
### Running Playwright outside docker
It's possible to run `Playwright` outside of the container, this remove the need to rebuild the image for each change.
You'll additionally need `nodejs` then run:
```bash
npm install
npx playwright install-deps
npx playwright install firefox
```
## Usage
To run all the tests:
```bash
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright
```
To force a rebuild of the Playwright image:
```bash
DOCKER_BUILDKIT=1 docker compose --env-file test.env build Playwright
```
To access the ui to easily run test individually and debug if needed (will not work in docker):
```bash
npx playwright test --ui
```
### DB
Projects are configured to allow to run tests only on specific database.
\
You can use:
```bash
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=mariadb
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=mysql
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=postgres
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite
```
### SSO
To run the SSO tests:
```bash
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project sso-sqlite
```
### Keep services running
If you want you can keep the Db and Keycloak runnning (states are not impacted by the tests):
```bash
PW_KEEP_SERVICE_RUNNNING=true npx playwright test
```
### Running specific tests
To run a whole file you can :
```bash
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite tests/login.spec.ts
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite login
```
To run only a specifc test (It might fail if it has dependency):
```bash
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite -g "Account creation"
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite tests/login.spec.ts:16
```
## Writing scenario
When creating new scenario use the recorder to more easily identify elements (in general try to rely on visible hint to identify elements and not hidden ids).
This does not start the server, you will need to start it manually.
```bash
npx playwright codegen "http://127.0.0.1:8000"
```
## Override web-vault
It's possible to change the `web-vault` used by referencing a different `bw_web_builds` commit.
```bash
export PW_WV_REPO_URL=https://github.com/Timshel/oidc_web_builds.git
export PW_WV_COMMIT_HASH=8707dc76df3f0cceef2be5bfae37bb29bd17fae6
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env build Playwright
```
# OpenID Connect test setup
Additionally this `docker-compose` template allow to run locally `VaultWarden`, [Keycloak](https://www.keycloak.org/) and [Maildev](https://github.com/timshel/maildev) to test OIDC.
## Setup
This rely on `docker` and the `compose` [plugin](https://docs.docker.com/compose/install/).
First create a copy of `.env.template` as `.env` (This is done to prevent commiting your custom settings, Ex `SMTP_`).
## Usage
Then start the stack (the `profile` is required to run `Vaultwarden`) :
```bash
> docker compose --profile vaultwarden --env-file .env up
....
keycloakSetup_1 | Logging into http://127.0.0.1:8080 as user admin of realm master
keycloakSetup_1 | Created new realm with id 'test'
keycloakSetup_1 | 74af4933-e386-4e64-ba15-a7b61212c45e
oidc_keycloakSetup_1 exited with code 0
```
Wait until `oidc_keycloakSetup_1 exited with code 0` which indicate the correct setup of the Keycloak realm, client and user (It's normal for this container to stop once the configuration is done).
Then you can access :
- `VaultWarden` on http://0.0.0.0:8000 with the default user `test@yopmail.com/test`.
- `Keycloak` on http://0.0.0.0:8080/admin/master/console/ with the default user `admin/admin`
- `Maildev` on http://0.0.0.0:1080
To proceed with an SSO login after you enter the email, on the screen prompting for `Master Password` the SSO button should be visible.
To use your computer external ip (for example when testing with a phone) you will have to configure `KC_HTTP_HOST` and `DOMAIN`.
## Running only Keycloak
You can run just `Keycloak` with `--profile keycloak`:
```bash
> docker compose --profile keycloak --env-file .env up
```
When running with a local VaultWarden, you can use a front-end build from [dani-garcia/bw_web_builds](https://github.com/dani-garcia/bw_web_builds/releases).
## Rebuilding the Vaultwarden
To force rebuilding the Vaultwarden image you can run
```bash
docker compose --profile vaultwarden --env-file .env build VaultwardenPrebuild Vaultwarden
```
## Configuration
All configuration for `keycloak` / `VaultWarden` / `keycloak_setup.sh` can be found in [.env](.env.template).
The content of the file will be loaded as environment variables in all containers.
- `keycloak` [configuration](https://www.keycloak.org/server/all-config) include `KEYCLOAK_ADMIN` / `KEYCLOAK_ADMIN_PASSWORD` and any variable prefixed `KC_` ([more information](https://www.keycloak.org/server/configuration#_example_configuring_the_db_url_host_parameter)).
- All `VaultWarden` configuration can be set (EX: `SMTP_*`)
## Cleanup
Use `docker compose --profile vaultWarden down`.

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

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

40
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"]

40
playwright/compose/warden/Dockerfile

@ -0,0 +1,40 @@
FROM playwright_oidc_vaultwarden_prebuilt AS prebuilt
FROM node:18-bookworm AS build
ARG REPO_URL
ARG COMMIT_HASH
ENV REPO_URL=$REPO_URL
ENV COMMIT_HASH=$COMMIT_HASH
COPY --from=prebuilt /web-vault /web-vault
COPY build.sh /build.sh
RUN /build.sh
######################## RUNTIME IMAGE ########################
FROM docker.io/library/debian:bookworm-slim
ENV DEBIAN_FRONTEND=noninteractive
# Create data folder and Install needed libraries
RUN mkdir /data && \
apt-get update && apt-get install -y \
--no-install-recommends \
ca-certificates \
curl \
libmariadb-dev-compat \
libpq5 \
openssl && \
rm -rf /var/lib/apt/lists/*
# Copies the files from the context (Rocket.toml file and web-vault)
# and the binary from the "build" stage to the current stage
WORKDIR /
COPY --from=prebuilt /start.sh .
COPY --from=prebuilt /vaultwarden .
COPY --from=build /web-vault ./web-vault
ENTRYPOINT ["/start.sh"]

24
playwright/compose/warden/build.sh

@ -0,0 +1,24 @@
#!/bin/bash
echo $REPO_URL
echo $COMMIT_HASH
if [[ ! -z "$REPO_URL" ]] && [[ ! -z "$COMMIT_HASH" ]] ; then
rm -rf /web-vault
mkdir bw_web_builds;
cd bw_web_builds;
git -c init.defaultBranch=main init
git remote add origin "$REPO_URL"
git fetch --depth 1 origin "$COMMIT_HASH"
git -c advice.detachedHead=false checkout FETCH_HEAD
export VAULT_VERSION=$(cat Dockerfile | grep "ARG VAULT_VERSION" | cut -d "=" -f2)
./scripts/checkout_web_vault.sh
./scripts/patch_web_vault.sh
./scripts/build_web_vault.sh
printf '{"version":"%s"}' "$COMMIT_HASH" > ./web-vault/apps/web/build/vw-version.json
mv ./web-vault/apps/web/build /web-vault
fi

124
playwright/docker-compose.yml

@ -0,0 +1,124 @@
services:
VaultwardenPrebuild:
profiles: ["playwright", "vaultwarden"]
container_name: playwright_oidc_vaultwarden_prebuilt
image: playwright_oidc_vaultwarden_prebuilt
build:
context: ..
dockerfile: Dockerfile
entrypoint: /bin/bash
restart: "no"
Vaultwarden:
profiles: ["playwright", "vaultwarden"]
container_name: playwright_oidc_vaultwarden-${ENV:-dev}
image: playwright_oidc_vaultwarden-${ENV:-dev}
network_mode: "host"
build:
context: compose/warden
dockerfile: Dockerfile
args:
REPO_URL: ${PW_WV_REPO_URL:-}
COMMIT_HASH: ${PW_WV_COMMIT_HASH:-}
env_file: ${DC_ENV_FILE:-.env}
environment:
- DATABASE_URL
- I_REALLY_WANT_VOLATILE_STORAGE
- LOG_LEVEL
- LOGIN_RATELIMIT_MAX_BURST
- SMTP_HOST
- SMTP_FROM
- SMTP_DEBUG
- SSO_DEBUG_TOKENS
- SSO_FRONTEND
- SSO_ENABLED
- SSO_ONLY
restart: "no"
depends_on:
- VaultwardenPrebuild
Playwright:
profiles: ["playwright"]
container_name: playwright_oidc_playwright
image: playwright_oidc_playwright
network_mode: "host"
build:
context: .
dockerfile: compose/playwright/Dockerfile
environment:
- PW_WV_REPO_URL
- PW_WV_COMMIT_HASH
restart: "no"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ..:/project
Mariadb:
profiles: ["playwright"]
container_name: playwright_mariadb
image: mariadb:11.2.4
env_file: test.env
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
start_period: 10s
interval: 10s
ports:
- ${MARIADB_PORT}:3306
Mysql:
profiles: ["playwright"]
container_name: playwright_mysql
image: mysql:8.4.1
env_file: test.env
healthcheck:
test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"]
start_period: 10s
interval: 10s
ports:
- ${MYSQL_PORT}:3306
Postgres:
profiles: ["playwright"]
container_name: playwright_postgres
image: postgres:16.3
env_file: test.env
healthcheck:
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
start_period: 20s
interval: 30s
ports:
- ${POSTGRES_PORT}:5432
Maildev:
profiles: ["vaultwarden", "maildev"]
container_name: maildev
image: timshel/maildev:3.0.4
ports:
- ${SMTP_PORT}:1025
- 1080:1080
Keycloak:
profiles: ["keycloak", "vaultwarden"]
container_name: keycloak-${ENV:-dev}
image: quay.io/keycloak/keycloak:25.0.4
network_mode: "host"
command:
- start-dev
env_file: ${DC_ENV_FILE:-.env}
KeycloakSetup:
profiles: ["keycloak", "vaultwarden"]
container_name: keycloakSetup-${ENV:-dev}
image: keycloak_setup-${ENV:-dev}
build:
context: compose/keycloak
dockerfile: Dockerfile
args:
KEYCLOAK_VERSION: 25.0.4
JAVA_URL: https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_linux-x64_bin.tar.gz
JAVA_VERSION: 21.0.2
network_mode: "host"
depends_on:
- Keycloak
restart: "no"
env_file: ${DC_ENV_FILE:-.env}

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

246
playwright/global-utils.ts

@ -0,0 +1,246 @@
import { expect, type Browser, type TestInfo } from '@playwright/test';
import { EventEmitter } from "events";
import { type Mail, MailServer } from 'maildev';
import { execSync } from 'node:child_process';
import dotenv from 'dotenv';
import dotenvExpand from 'dotenv-expand';
const fs = require("fs");
const { spawn } = require('node:child_process');
export function loadEnv(){
var myEnv = dotenv.config({ path: 'test.env' });
dotenvExpand.expand(myEnv);
return {
user1: {
email: process.env.TEST_USER_MAIL,
name: process.env.TEST_USER,
password: process.env.TEST_USER_PASSWORD,
},
user2: {
email: process.env.TEST_USER2_MAIL,
name: process.env.TEST_USER2,
password: process.env.TEST_USER2_PASSWORD,
},
user3: {
email: process.env.TEST_USER3_MAIL,
name: process.env.TEST_USER3,
password: process.env.TEST_USER3_PASSWORD,
},
}
}
export async function waitFor(url: String, browser: Browser) {
var ready = false;
var context;
do {
try {
context = await browser.newContext();
const page = await context.newPage();
await page.waitForTimeout(500);
const result = await page.goto(url);
ready = result.status() === 200;
} catch(e) {
if( !e.message.includes("CONNECTION_REFUSED") ){
throw e;
}
} finally {
await context.close();
}
} while(!ready);
}
export function startComposeService(serviceName: String){
console.log(`Starting ${serviceName}`);
execSync(`docker compose --profile playwright --env-file test.env up -d ${serviceName}`);
}
export function stopComposeService(serviceName: String){
console.log(`Stopping ${serviceName}`);
execSync(`docker compose --profile playwright --env-file test.env stop ${serviceName}`);
}
function wipeSqlite(){
console.log(`Delete Vaultwarden container to wipe sqlite`);
execSync(`docker compose --env-file test.env stop Vaultwarden`);
execSync(`docker compose --env-file test.env rm -f Vaultwarden`);
}
async function wipeMariaDB(){
var mysql = require('mysql2/promise');
var ready = false;
var connection;
do {
try {
connection = await mysql.createConnection({
user: process.env.MARIADB_USER,
host: "127.0.0.1",
database: process.env.MARIADB_DATABASE,
password: process.env.MARIADB_PASSWORD,
port: process.env.MARIADB_PORT,
});
await connection.execute(`DROP DATABASE ${process.env.MARIADB_DATABASE}`);
await connection.execute(`CREATE DATABASE ${process.env.MARIADB_DATABASE}`);
console.log('Successfully wiped mariadb');
ready = true;
} catch (err) {
console.log(`Error when wiping mariadb: ${err}`);
} finally {
if( connection ){
connection.end();
}
}
await new Promise(r => setTimeout(r, 1000));
} while(!ready);
}
async function wipeMysqlDB(){
var mysql = require('mysql2/promise');
var ready = false;
var connection;
do{
try {
connection = await mysql.createConnection({
user: process.env.MYSQL_USER,
host: "127.0.0.1",
database: process.env.MYSQL_DATABASE,
password: process.env.MYSQL_PASSWORD,
port: process.env.MYSQL_PORT,
});
await connection.execute(`DROP DATABASE ${process.env.MYSQL_DATABASE}`);
await connection.execute(`CREATE DATABASE ${process.env.MYSQL_DATABASE}`);
console.log('Successfully wiped mysql');
ready = true;
} catch (err) {
console.log(`Error when wiping mysql: ${err}`);
} finally {
if( connection ){
connection.end();
}
}
await new Promise(r => setTimeout(r, 1000));
} while(!ready);
}
async function wipePostgres(){
const { Client } = require('pg');
const client = new Client({
user: process.env.POSTGRES_USER,
host: "127.0.0.1",
database: "postgres",
password: process.env.POSTGRES_PASSWORD,
port: process.env.POSTGRES_PORT,
});
try {
await client.connect();
await client.query(`DROP DATABASE ${process.env.POSTGRES_DB}`);
await client.query(`CREATE DATABASE ${process.env.POSTGRES_DB}`);
console.log('Successfully wiped postgres');
} catch (err) {
console.log(`Error when wiping postgres: ${err}`);
} finally {
client.end();
}
}
function dbConfig(testInfo: TestInfo){
switch(testInfo.project.name) {
case "postgres":
case "sso-postgres":
return { DATABASE_URL: `postgresql://${process.env.POSTGRES_USER}:${process.env.POSTGRES_PASSWORD}@127.0.0.1:${process.env.POSTGRES_PORT}/${process.env.POSTGRES_DB}` };
case "mariadb":
case "sso-mariadb":
return { DATABASE_URL: `mysql://${process.env.MARIADB_USER}:${process.env.MARIADB_PASSWORD}@127.0.0.1:${process.env.MARIADB_PORT}/${process.env.MARIADB_DATABASE}` };
case "mysql":
case "sso-mysql":
return { DATABASE_URL: `mysql://${process.env.MYSQL_USER}:${process.env.MYSQL_PASSWORD}@127.0.0.1:${process.env.MYSQL_PORT}/${process.env.MYSQL_DATABASE}`};
case "sqlite":
case "sso-sqlite":
return { I_REALLY_WANT_VOLATILE_STORAGE: true };
default:
throw new Error(`Unknow database name: ${testInfo.project.name}`);
}
}
/**
* All parameters passed in `env` need to be added to the docker-compose.yml
**/
export async function startVault(browser: Browser, testInfo: TestInfo, env = {}, resetDB: Boolean = true) {
if( resetDB ){
switch(testInfo.project.name) {
case "postgres":
case "sso-postgres":
await wipePostgres();
break;
case "mariadb":
case "sso-mariadb":
await wipeMariaDB();
break;
case "mysql":
case "sso-mysql":
await wipeMysqlDB();
break;
case "sqlite":
case "sso-sqlite":
wipeSqlite();
break;
default:
throw new Error(`Unknow database name: ${testInfo.project.name}`);
}
}
console.log(`Starting Vaultwarden`);
execSync(`docker compose --profile playwright --env-file test.env up -d Vaultwarden`, {
env: { ...env, ...dbConfig(testInfo) },
});
await waitFor("/", browser);
console.log(`Vaultwarden running on: ${process.env.DOMAIN}`);
}
export async function stopVault(force: boolean = false) {
if( force === false && process.env.PW_KEEP_SERVICE_RUNNNING === "true" ) {
console.log(`Keep vaultwarden running on: ${process.env.DOMAIN}`);
} else {
console.log(`Vaultwarden stopping`);
execSync(`docker compose --profile playwright --env-file test.env stop Vaultwarden`);
}
}
export async function restartVault(page: Page, testInfo: TestInfo, env, resetDB: Boolean = true) {
stopVault(true);
return startVault(page.context().browser(), testInfo, env, resetDB);
}
export async function checkNotification(page: Page, hasText: string) {
await expect(page.locator('bit-toast').filter({ hasText })).toBeVisible();
await page.locator('bit-toast').filter({ hasText }).getByRole('button').click();
await expect(page.locator('bit-toast').filter({ hasText })).toHaveCount(0);
}
export async function cleanLanding(page: Page) {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('button').nth(0)).toBeVisible();
const logged = await page.getByRole('button', { name: 'Log out' }).count();
if( logged > 0 ){
await page.getByRole('button', { name: 'Log out' }).click();
await page.getByRole('button', { name: 'Log out' }).click();
}
}
export async function logout(test: Test, page: Page, user: { name: string }) {
await test.step('logout', async () => {
await page.getByRole('button', { name: user.name, exact: true }).click();
await page.getByRole('menuitem', { name: 'Log out' }).click();
await expect(page.getByRole('heading', { name: 'Log in' })).toBeVisible();
});
}

2547
playwright/package-lock.json

File diff suppressed because it is too large

21
playwright/package.json

@ -0,0 +1,21 @@
{
"name": "scenarios",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.53.0",
"dotenv": "^16.5.0",
"dotenv-expand": "^12.0.2",
"maildev": "npm:@timshel_npm/maildev@^3.1.2"
},
"dependencies": {
"mysql2": "^3.14.1",
"otpauth": "^9.4.0",
"pg": "^8.16.0"
}
}

143
playwright/playwright.config.ts

@ -0,0 +1,143 @@
import { defineConfig, devices } from '@playwright/test';
import { exec } from 'node:child_process';
const utils = require('./global-utils');
utils.loadEnv();
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './.',
/* Run tests in files in parallel */
fullyParallel: false,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
retries: 0,
workers: 1,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Long global timeout for complex tests
* But short action/nav/expect timeouts to fail on specific step (raise locally if not enough).
*/
timeout: 120 * 1000,
actionTimeout: 10 * 1000,
navigationTimeout: 10 * 1000,
expect: { timeout: 10 * 1000 },
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: process.env.DOMAIN,
browserName: 'firefox',
locale: 'en-GB',
timezoneId: 'Europe/London',
/* Always collect trace (other values add random test failures) See https://playwright.dev/docs/trace-viewer */
trace: 'on',
viewport: {
width: 1080,
height: 720,
},
video: "on",
},
/* Configure projects for major browsers */
projects: [
{
name: 'mariadb-setup',
testMatch: 'tests/setups/db-setup.ts',
use: { serviceName: "Mariadb" },
teardown: 'mariadb-teardown',
},
{
name: 'mysql-setup',
testMatch: 'tests/setups/db-setup.ts',
use: { serviceName: "Mysql" },
teardown: 'mysql-teardown',
},
{
name: 'postgres-setup',
testMatch: 'tests/setups/db-setup.ts',
use: { serviceName: "Postgres" },
teardown: 'postgres-teardown',
},
{
name: 'sso-setup',
testMatch: 'tests/setups/sso-setup.ts',
teardown: 'sso-teardown',
},
{
name: 'mariadb',
testMatch: 'tests/*.spec.ts',
testIgnore: 'tests/sso_*.spec.ts',
dependencies: ['mariadb-setup'],
},
{
name: 'mysql',
testMatch: 'tests/*.spec.ts',
testIgnore: 'tests/sso_*.spec.ts',
dependencies: ['mysql-setup'],
},
{
name: 'postgres',
testMatch: 'tests/*.spec.ts',
testIgnore: 'tests/sso_*.spec.ts',
dependencies: ['postgres-setup'],
},
{
name: 'sqlite',
testMatch: 'tests/*.spec.ts',
testIgnore: 'tests/sso_*.spec.ts',
},
{
name: 'sso-mariadb',
testMatch: 'tests/sso_*.spec.ts',
dependencies: ['sso-setup', 'mariadb-setup'],
},
{
name: 'sso-mysql',
testMatch: 'tests/sso_*.spec.ts',
dependencies: ['sso-setup', 'mysql-setup'],
},
{
name: 'sso-postgres',
testMatch: 'tests/sso_*.spec.ts',
dependencies: ['sso-setup', 'postgres-setup'],
},
{
name: 'sso-sqlite',
testMatch: 'tests/sso_*.spec.ts',
dependencies: ['sso-setup'],
},
{
name: 'mariadb-teardown',
testMatch: 'tests/setups/db-teardown.ts',
use: { serviceName: "Mariadb" },
},
{
name: 'mysql-teardown',
testMatch: 'tests/setups/db-teardown.ts',
use: { serviceName: "Mysql" },
},
{
name: 'postgres-teardown',
testMatch: 'tests/setups/db-teardown.ts',
use: { serviceName: "Postgres" },
},
{
name: 'sso-teardown',
testMatch: 'tests/setups/sso-teardown.ts',
},
],
globalSetup: require.resolve('./global-setup'),
});

93
playwright/test.env

@ -0,0 +1,93 @@
##################################################################
### Shared Playwright conf test file Vaultwarden and Databases ###
##################################################################
ENV=test
DC_ENV_FILE=test.env
COMPOSE_IGNORE_ORPHANS=True
DOCKER_BUILDKIT=1
#####################
# Playwright Config #
#####################
PW_KEEP_SERVICE_RUNNNING=${PW_KEEP_SERVICE_RUNNNING:-false}
PW_SMTP_FROM=vaultwarden@playwright.test
#####################
# Maildev Config #
#####################
MAILDEV_HTTP_PORT=1081
MAILDEV_SMTP_PORT=1026
MAILDEV_HOST=127.0.0.1
################
# Users Config #
################
TEST_USER=test
TEST_USER_PASSWORD=Master Password
TEST_USER_MAIL=${TEST_USER}@example.com
TEST_USER2=test2
TEST_USER2_PASSWORD=Master Password
TEST_USER2_MAIL=${TEST_USER2}@example.com
TEST_USER3=test3
TEST_USER3_PASSWORD=Master Password
TEST_USER3_MAIL=${TEST_USER3}@example.com
###################
# Keycloak Config #
###################
KEYCLOAK_ADMIN=admin
KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN}
KC_HTTP_HOST=127.0.0.1
KC_HTTP_PORT=8081
# Script parameters (use Keycloak and VaultWarden config too)
TEST_REALM=test
DUMMY_REALM=dummy
DUMMY_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${DUMMY_REALM}
######################
# Vaultwarden Config #
######################
ROCKET_PORT=8003
DOMAIN=http://127.0.0.1:${ROCKET_PORT}
LOG_LEVEL=info,oidcwarden::sso=debug
LOGIN_RATELIMIT_MAX_BURST=100
SMTP_SECURITY=off
SMTP_PORT=${MAILDEV_SMTP_PORT}
SMTP_FROM_NAME=Vaultwarden
SMTP_TIMEOUT=5
SSO_CLIENT_ID=warden
SSO_CLIENT_SECRET=warden
SSO_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${TEST_REALM}
SSO_DEBUG_TOKENS=true
###########################
# Docker MariaDb container#
###########################
MARIADB_PORT=3307
MARIADB_ROOT_PASSWORD=warden
MARIADB_USER=warden
MARIADB_PASSWORD=warden
MARIADB_DATABASE=warden
###########################
# Docker Mysql container#
###########################
MYSQL_PORT=3309
MYSQL_ROOT_PASSWORD=warden
MYSQL_USER=warden
MYSQL_PASSWORD=warden
MYSQL_DATABASE=warden
############################
# Docker Postgres container#
############################
POSTGRES_PORT=5433
POSTGRES_USER=warden
POSTGRES_PASSWORD=warden
POSTGRES_DB=warden

37
playwright/tests/collection.spec.ts

@ -0,0 +1,37 @@
import { test, expect, type TestInfo } from '@playwright/test';
import * as utils from "../global-utils";
import { createAccount } from './setups/user';
let users = utils.loadEnv();
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
await utils.startVault(browser, testInfo);
});
test.afterAll('Teardown', async ({}) => {
utils.stopVault();
});
test('Create', async ({ page }) => {
await createAccount(test, page, users.user1);
await test.step('Create Org', async () => {
await page.getByRole('link', { name: 'New organisation' }).click();
await page.getByLabel('Organisation name (required)').fill('Test');
await page.getByRole('button', { name: 'Submit' }).click();
await page.locator('div').filter({ hasText: 'Members' }).nth(2).click();
await utils.checkNotification(page, 'Organisation created');
});
await test.step('Create Collection', async () => {
await page.getByRole('link', { name: 'Collections' }).click();
await page.getByRole('button', { name: 'New' }).click();
await page.getByRole('menuitem', { name: 'Collection' }).click();
await page.getByLabel('Name (required)').fill('RandomCollec');
await page.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page, 'Created collection RandomCollec');
await expect(page.getByRole('button', { name: 'RandomCollec' })).toBeVisible();
});
});

100
playwright/tests/login.smtp.spec.ts

@ -0,0 +1,100 @@
import { test, expect, type TestInfo } from '@playwright/test';
import { MailDev } from 'maildev';
const utils = require('../global-utils');
import { createAccount, logUser } from './setups/user';
import { activateEmail, retrieveEmailCode, disableEmail } from './setups/2fa';
let users = utils.loadEnv();
let mailserver;
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
mailserver = new MailDev({
port: process.env.MAILDEV_SMTP_PORT,
web: { port: process.env.MAILDEV_HTTP_PORT },
})
await mailserver.listen();
await utils.startVault(browser, testInfo, {
SMTP_HOST: process.env.MAILDEV_HOST,
SMTP_FROM: process.env.PW_SMTP_FROM,
});
});
test.afterAll('Teardown', async ({}) => {
utils.stopVault();
if( mailserver ){
await mailserver.close();
}
});
test('Account creation', async ({ page }) => {
const mailBuffer = mailserver.buffer(users.user1.email);
await createAccount(test, page, users.user1, mailBuffer);
mailBuffer.close();
});
test('Login', async ({ context, page }) => {
const mailBuffer = mailserver.buffer(users.user1.email);
await logUser(test, page, users.user1, mailBuffer);
await test.step('verify email', async () => {
await page.getByText('Verify your account\'s email').click();
await expect(page.getByText('Verify your account\'s email')).toBeVisible();
await page.getByRole('button', { name: 'Send email' }).click();
await utils.checkNotification(page, 'Check your email inbox for a verification link');
const verify = await mailBuffer.expect((m) => m.subject === "Verify Your Email");
expect(verify.from[0]?.address).toBe(process.env.PW_SMTP_FROM);
const page2 = await context.newPage();
await page2.setContent(verify.html);
const link = await page2.getByTestId("verify").getAttribute("href");
await page2.close();
await page.goto(link);
await utils.checkNotification(page, 'Account email verified');
});
mailBuffer.close();
});
test('Activate 2fa', async ({ page }) => {
const emails = mailserver.buffer(users.user1.email);
await logUser(test, page, users.user1);
await activateEmail(test, page, users.user1, emails);
emails.close();
});
test('2fa', async ({ page }) => {
const emails = mailserver.buffer(users.user1.email);
await test.step('login', async () => {
await page.goto('/');
await page.getByLabel(/Email address/).fill(users.user1.email);
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByLabel('Master password').fill(users.user1.password);
await page.getByRole('button', { name: 'Log in with master password' }).click();
await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible();
const code = await retrieveEmailCode(test, page, emails);
await page.getByLabel(/Verification code/).fill(code);
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page).toHaveTitle(/Vaults/);
})
await disableEmail(test, page, users.user1);
emails.close();
});

51
playwright/tests/login.spec.ts

@ -0,0 +1,51 @@
import { test, expect, type Page, type TestInfo } from '@playwright/test';
import * as OTPAuth from "otpauth";
import * as utils from "../global-utils";
import { createAccount, logUser } from './setups/user';
import { activateTOTP, disableTOTP } from './setups/2fa';
let users = utils.loadEnv();
let totp;
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
await utils.startVault(browser, testInfo, {});
});
test.afterAll('Teardown', async ({}) => {
utils.stopVault();
});
test('Account creation', async ({ page }) => {
await createAccount(test, page, users.user1);
});
test('Master password login', async ({ page }) => {
await logUser(test, page, users.user1);
});
test('Authenticator 2fa', async ({ page }) => {
await logUser(test, page, users.user1);
let totp = await activateTOTP(test, page, users.user1);
await utils.logout(test, page, users.user1);
await test.step('login', async () => {
let timestamp = Date.now(); // Needed to use the next token
timestamp = timestamp + (totp.period - (Math.floor(timestamp / 1000) % totp.period) + 1) * 1000;
await page.getByLabel(/Email address/).fill(users.user1.email);
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByLabel('Master password').fill(users.user1.password);
await page.getByRole('button', { name: 'Log in with master password' }).click();
await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible();
await page.getByLabel(/Verification code/).fill(totp.generate({timestamp}));
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page).toHaveTitle(/Vaultwarden Web/);
});
await disableTOTP(test, page, users.user1);
});

115
playwright/tests/organization.smtp.spec.ts

@ -0,0 +1,115 @@
import { test, expect, type TestInfo } from '@playwright/test';
import { MailDev } from 'maildev';
import * as utils from '../global-utils';
import * as orgs from './setups/orgs';
import { createAccount, logUser } from './setups/user';
let users = utils.loadEnv();
let mailServer, mail1Buffer, mail2Buffer, mail3Buffer;
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
mailServer = new MailDev({
port: process.env.MAILDEV_SMTP_PORT,
web: { port: process.env.MAILDEV_HTTP_PORT },
})
await mailServer.listen();
await utils.startVault(browser, testInfo, {
SMTP_HOST: process.env.MAILDEV_HOST,
SMTP_FROM: process.env.PW_SMTP_FROM,
});
mail1Buffer = mailServer.buffer(users.user1.email);
mail2Buffer = mailServer.buffer(users.user2.email);
mail3Buffer = mailServer.buffer(users.user3.email);
});
test.afterAll('Teardown', async ({}, testInfo: TestInfo) => {
utils.stopVault(testInfo);
[mail1Buffer, mail2Buffer, mail3Buffer, mailServer].map((m) => m?.close());
});
test('Create user3', async ({ page }) => {
await createAccount(test, page, users.user3, mail3Buffer);
});
test('Invite users', async ({ page }) => {
await createAccount(test, page, users.user1, mail1Buffer);
await orgs.create(test, page, 'Test');
await orgs.members(test, page, 'Test');
await orgs.invite(test, page, 'Test', users.user2.email);
await orgs.invite(test, page, 'Test', users.user3.email, {
navigate: false,
});
});
test('invited with new account', async ({ page }) => {
const invited = await mail2Buffer.expect((mail) => mail.subject === 'Join Test');
await test.step('Create account', async () => {
await page.setContent(invited.html);
const link = await page.getByTestId('invite').getAttribute('href');
await page.goto(link);
await expect(page).toHaveTitle(/Create account | Vaultwarden Web/);
//await page.getByLabel('Name').fill(users.user2.name);
await page.getByLabel('New master password (required)', { exact: true }).fill(users.user2.password);
await page.getByLabel('Confirm new master password (').fill(users.user2.password);
await page.getByRole('button', { name: 'Create account' }).click();
await utils.checkNotification(page, 'Your new account has been created');
// Redirected to the vault
await expect(page).toHaveTitle('Vaults | Vaultwarden Web');
await utils.checkNotification(page, 'You have been logged in!');
await utils.checkNotification(page, 'Invitation accepted');
});
await test.step('Check mails', async () => {
await mail2Buffer.expect((m) => m.subject === 'Welcome');
await mail2Buffer.expect((m) => m.subject === 'New Device Logged In From Firefox');
await mail1Buffer.expect((m) => m.subject.includes('Invitation to Test accepted'));
});
});
test('invited with existing account', async ({ page }) => {
const invited = await mail3Buffer.expect((mail) => mail.subject === 'Join Test');
await page.setContent(invited.html);
const link = await page.getByTestId('invite').getAttribute('href');
await page.goto(link);
// We should be on login page with email prefilled
await expect(page).toHaveTitle(/Vaultwarden Web/);
await page.getByRole('button', { name: 'Continue' }).click();
// Unlock page
await page.getByLabel('Master password').fill(users.user3.password);
await page.getByRole('button', { name: 'Log in with master password' }).click();
// We are now in the default vault page
await expect(page).toHaveTitle(/Vaultwarden Web/);
await utils.checkNotification(page, 'Invitation accepted');
await mail3Buffer.expect((m) => m.subject === 'New Device Logged In From Firefox');
await mail1Buffer.expect((m) => m.subject.includes('Invitation to Test accepted'));
});
test('Confirm invited user', async ({ page }) => {
await logUser(test, page, users.user1, mail1Buffer);
await orgs.members(test, page, 'Test');
await orgs.confirm(test, page, 'Test', users.user2.email);
await mail2Buffer.expect((m) => m.subject.includes('Invitation to Test confirmed'));
});
test('Organization is visible', async ({ page }) => {
await logUser(test, page, users.user2, mail2Buffer);
await page.getByRole('button', { name: 'vault: Test', exact: true }).click();
await expect(page.getByLabel('Filter: Default collection')).toBeVisible();
});

54
playwright/tests/organization.spec.ts

@ -0,0 +1,54 @@
import { test, expect, type TestInfo } from '@playwright/test';
import { MailDev } from 'maildev';
import * as utils from "../global-utils";
import * as orgs from './setups/orgs';
import { createAccount, logUser } from './setups/user';
let users = utils.loadEnv();
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
await utils.startVault(browser, testInfo);
});
test.afterAll('Teardown', async ({}) => {
utils.stopVault();
});
test('Invite', async ({ page }) => {
await createAccount(test, page, users.user3);
await createAccount(test, page, users.user1);
await orgs.create(test, page, 'New organisation');
await orgs.members(test, page, 'New organisation');
await test.step('missing user2', async () => {
await orgs.invite(test, page, 'New organisation', users.user2.email);
await expect(page.getByRole('row', { name: users.user2.email })).toHaveText(/Invited/);
});
await test.step('existing user3', async () => {
await orgs.invite(test, page, 'New organisation', users.user3.email);
await expect(page.getByRole('row', { name: users.user3.email })).toHaveText(/Needs confirmation/);
await orgs.confirm(test, page, 'New organisation', users.user3.email);
});
await test.step('confirm user2', async () => {
await createAccount(test, page, users.user2);
await logUser(test, page, users.user1);
await orgs.members(test, page, 'New organisation');
await orgs.confirm(test, page, 'New organisation', users.user2.email);
});
await test.step('Org visible user2 ', async () => {
await logUser(test, page, users.user2);
await page.getByRole('button', { name: 'vault: New organisation', exact: true }).click();
await expect(page.getByLabel('Filter: Default collection')).toBeVisible();
});
await test.step('Org visible user3 ', async () => {
await logUser(test, page, users.user3);
await page.getByRole('button', { name: 'vault: New organisation', exact: true }).click();
await expect(page.getByLabel('Filter: Default collection')).toBeVisible();
});
});

92
playwright/tests/setups/2fa.ts

@ -0,0 +1,92 @@
import { expect, type Page, Test } from '@playwright/test';
import { type MailBuffer } from 'maildev';
import * as OTPAuth from "otpauth";
import * as utils from '../../global-utils';
export async function activateTOTP(test: Test, page: Page, user: { name: string, password: string }): OTPAuth.TOTP {
return await test.step('Activate TOTP 2FA', async () => {
await page.getByRole('button', { name: user.name }).click();
await page.getByRole('menuitem', { name: 'Account settings' }).click();
await page.getByRole('link', { name: 'Security' }).click();
await page.getByRole('link', { name: 'Two-step login' }).click();
await page.locator('bit-item').filter({ hasText: /Authenticator app/ }).getByRole('button').click();
await page.getByLabel('Master password (required)').fill(user.password);
await page.getByRole('button', { name: 'Continue' }).click();
const secret = await page.getByLabel('Key').innerText();
let totp = new OTPAuth.TOTP({ secret, period: 30 });
await page.getByLabel(/Verification code/).fill(totp.generate());
await page.getByRole('button', { name: 'Turn on' }).click();
await page.getByRole('heading', { name: 'Turned on', exact: true });
await page.getByLabel('Close').click();
return totp;
})
}
export async function disableTOTP(test: Test, page: Page, user: { password: string }) {
await test.step('Disable TOTP 2FA', async () => {
await page.getByRole('button', { name: 'Test' }).click();
await page.getByRole('menuitem', { name: 'Account settings' }).click();
await page.getByRole('link', { name: 'Security' }).click();
await page.getByRole('link', { name: 'Two-step login' }).click();
await page.locator('bit-item').filter({ hasText: /Authenticator app/ }).getByRole('button').click();
await page.getByLabel('Master password (required)').click();
await page.getByLabel('Master password (required)').fill(user.password);
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Turn off' }).click();
await page.getByRole('button', { name: 'Yes' }).click();
await utils.checkNotification(page, 'Two-step login provider turned off');
});
}
export async function activateEmail(test: Test, page: Page, user: { name: string, password: string }, mailBuffer: MailBuffer) {
await test.step('Activate Email 2FA', async () => {
await page.getByRole('button', { name: user.name }).click();
await page.getByRole('menuitem', { name: 'Account settings' }).click();
await page.getByRole('link', { name: 'Security' }).click();
await page.getByRole('link', { name: 'Two-step login' }).click();
await page.locator('bit-item').filter({ hasText: 'Email Email Enter a code sent' }).getByRole('button').click();
await page.getByLabel('Master password (required)').fill(user.password);
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Send email' }).click();
});
let code = await retrieveEmailCode(test, page, mailBuffer);
await test.step('input code', async () => {
await page.getByLabel('2. Enter the resulting 6').fill(code);
await page.getByRole('button', { name: 'Turn on' }).click();
await page.getByRole('heading', { name: 'Turned on', exact: true });
});
}
export async function retrieveEmailCode(test: Test, page: Page, mailBuffer: MailBuffer): string {
return await test.step('retrieve code', async () => {
const codeMail = await mailBuffer.expect((mail) => mail.subject.includes("Login Verification Code"));
const page2 = await page.context().newPage();
await page2.setContent(codeMail.html);
const code = await page2.getByTestId("2fa").innerText();
await page2.close();
return code;
});
}
export async function disableEmail(test: Test, page: Page, user: { password: string }) {
await test.step('Disable Email 2FA', async () => {
await page.getByRole('button', { name: 'Test' }).click();
await page.getByRole('menuitem', { name: 'Account settings' }).click();
await page.getByRole('link', { name: 'Security' }).click();
await page.getByRole('link', { name: 'Two-step login' }).click();
await page.locator('bit-item').filter({ hasText: 'Email' }).getByRole('button').click();
await page.getByLabel('Master password (required)').click();
await page.getByLabel('Master password (required)').fill(user.password);
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Turn off' }).click();
await page.getByRole('button', { name: 'Yes' }).click();
await utils.checkNotification(page, 'Two-step login provider turned off');
});
}

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

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

9
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<TestOptions>({
serviceName: ['', { option: true }],
});

77
playwright/tests/setups/orgs.ts

@ -0,0 +1,77 @@
import { expect, type Browser,Page } from '@playwright/test';
import * as utils from '../../global-utils';
export async function create(test, page: Page, name: string) {
await test.step('Create Org', async () => {
await page.locator('a').filter({ hasText: 'Password Manager' }).first().click();
await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible();
await page.getByRole('link', { name: 'New organisation' }).click();
await page.getByLabel('Organisation name (required)').fill(name);
await page.getByRole('button', { name: 'Submit' }).click();
await utils.checkNotification(page, 'Organisation created');
});
}
export async function policies(test, page: Page, name: string) {
await test.step(`Navigate to ${name} policies`, async () => {
await page.locator('a').filter({ hasText: 'Admin Console' }).first().click();
await page.locator('org-switcher').getByLabel(/Toggle collapse/).click();
await page.locator('org-switcher').getByRole('link', { name: `${name}` }).first().click();
await expect(page.getByRole('heading', { name: `${name} collections` })).toBeVisible();
await page.getByRole('button', { name: 'Toggle collapse Settings' }).click();
await page.getByRole('link', { name: 'Policies' }).click();
await expect(page.getByRole('heading', { name: 'Policies' })).toBeVisible();
});
}
export async function members(test, page: Page, name: string) {
await test.step(`Navigate to ${name} members`, async () => {
await page.locator('a').filter({ hasText: 'Admin Console' }).first().click();
await page.locator('org-switcher').getByLabel(/Toggle collapse/).click();
await page.locator('org-switcher').getByRole('link', { name: `${name}` }).first().click();
await expect(page.getByRole('heading', { name: `${name} collections` })).toBeVisible();
await page.locator('div').filter({ hasText: 'Members' }).nth(2).click();
await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible();
await expect(page.getByRole('cell', { name: 'All' })).toBeVisible();
});
}
export async function invite(test, page: Page, name: string, email: string) {
await test.step(`Invite ${email}`, async () => {
await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible();
await page.getByRole('button', { name: 'Invite member' }).click();
await page.getByLabel('Email (required)').fill(email);
await page.getByRole('tab', { name: 'Collections' }).click();
await page.getByRole('combobox', { name: 'Permission' }).click();
await page.getByText('Edit items', { exact: true }).click();
await page.getByLabel('Select collections').click();
await page.getByText('Default collection').click();
await page.getByRole('cell', { name: 'Collection', exact: true }).click();
await page.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page, 'User(s) invited');
});
}
export async function confirm(test, page: Page, name: string, user_email: string) {
await test.step(`Confirm ${user_email}`, async () => {
await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible();
await page.getByRole('row').filter({hasText: user_email}).getByLabel('Options').click();
await page.getByRole('menuitem', { name: 'Confirm' }).click();
await expect(page.getByRole('heading', { name: 'Confirm user' })).toBeVisible();
await page.getByRole('button', { name: 'Confirm' }).click();
await utils.checkNotification(page, 'confirmed');
});
}
export async function revoke(test, page: Page, name: string, user_email: string) {
await test.step(`Revoke ${user_email}`, async () => {
await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible();
await page.getByRole('row').filter({hasText: user_email}).getByLabel('Options').click();
await page.getByRole('menuitem', { name: 'Revoke access' }).click();
await expect(page.getByRole('heading', { name: 'Revoke access' })).toBeVisible();
await page.getByRole('button', { name: 'Revoke access' }).click();
await utils.checkNotification(page, 'Revoked organisation access');
});
}

18
playwright/tests/setups/sso-setup.ts

@ -0,0 +1,18 @@
import { test, expect, type TestInfo } from '@playwright/test';
const { exec } = require('node:child_process');
const utils = require('../../global-utils');
utils.loadEnv();
test.beforeAll('Setup', async () => {
console.log("Starting Keycloak");
exec(`docker compose --profile keycloak --env-file test.env up`);
});
test('Keycloak is up', async ({ page }) => {
await utils.waitFor(process.env.SSO_AUTHORITY, page.context().browser());
// Dummy authority is created at the end of the setup
await utils.waitFor(process.env.DUMMY_AUTHORITY, page.context().browser());
console.log(`Keycloak running on: ${process.env.SSO_AUTHORITY}`);
});

15
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`);
}
});

138
playwright/tests/setups/sso.ts

@ -0,0 +1,138 @@
import { expect, type Page, Test } from '@playwright/test';
import { type MailBuffer, MailServer } from 'maildev';
import * as OTPAuth from "otpauth";
import * as utils from '../../global-utils';
import { retrieveEmailCode } from './2fa';
/**
* If a MailBuffer is passed it will be used and consume the expected emails
*/
export async function logNewUser(
test: Test,
page: Page,
user: { email: string, name: string, password: string },
options: { mailBuffer?: MailBuffer, override?: boolean } = {}
) {
await test.step(`Create user ${user.name}`, async () => {
await page.context().clearCookies();
await test.step('Landing page', async () => {
await utils.cleanLanding(page);
if( options.override ) {
await page.getByRole('button', { name: 'Continue' }).click();
} else {
await page.getByLabel(/Email address/).fill(user.email);
await page.getByRole('button', { name: /Use single sign-on/ }).click();
}
});
await test.step('Keycloak login', async () => {
await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible();
await page.getByLabel(/Username/).fill(user.name);
await page.getByLabel('Password', { exact: true }).fill(user.password);
await page.getByRole('button', { name: 'Sign In' }).click();
});
await test.step('Create Vault account', async () => {
await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible();
await page.getByLabel('New master password (required)', { exact: true }).fill(user.password);
await page.getByLabel('Confirm new master password (').fill(user.password);
await page.getByRole('button', { name: 'Create account' }).click();
});
await test.step('Default vault page', async () => {
await expect(page).toHaveTitle(/Vaultwarden Web/);
await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible();
});
await utils.checkNotification(page, 'Account successfully created!');
await utils.checkNotification(page, 'Invitation accepted');
if( options.mailBuffer ){
let mailBuffer = options.mailBuffer;
await test.step('Check emails', async () => {
await mailBuffer.expect((m) => m.subject === "Welcome");
await mailBuffer.expect((m) => m.subject.includes("New Device Logged"));
});
}
});
}
/**
* If a MailBuffer is passed it will be used and consume the expected emails
*/
export async function logUser(
test: Test,
page: Page,
user: { email: string, password: string },
options: {
mailBuffer ?: MailBuffer,
override?: boolean,
totp?: OTPAuth.TOTP,
mail2fa?: boolean,
} = {}
) {
let mailBuffer = options.mailBuffer;
await test.step(`Log user ${user.email}`, async () => {
await page.context().clearCookies();
await test.step('Landing page', async () => {
await utils.cleanLanding(page);
if( options.override ) {
await page.getByRole('button', { name: 'Continue' }).click();
} else {
await page.getByLabel(/Email address/).fill(user.email);
await page.getByRole('button', { name: /Use single sign-on/ }).click();
}
});
await test.step('Keycloak login', async () => {
await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible();
await page.getByLabel(/Username/).fill(user.name);
await page.getByLabel('Password', { exact: true }).fill(user.password);
await page.getByRole('button', { name: 'Sign In' }).click();
});
if( options.totp || options.mail2fa ){
let code;
await test.step('2FA check', async () => {
await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible();
if( options.totp ) {
const totp = options.totp;
let timestamp = Date.now(); // Needed to use the next token
timestamp = timestamp + (totp.period - (Math.floor(timestamp / 1000) % totp.period) + 1) * 1000;
code = totp.generate({timestamp});
} else if( options.mail2fa ){
code = await retrieveEmailCode(test, page, mailBuffer);
}
await page.getByLabel(/Verification code/).fill(code);
await page.getByRole('button', { name: 'Continue' }).click();
});
}
await test.step('Unlock vault', async () => {
await expect(page).toHaveTitle('Vaultwarden Web');
await expect(page.getByRole('heading', { name: 'Your vault is locked' })).toBeVisible();
await page.getByLabel('Master password').fill(user.password);
await page.getByRole('button', { name: 'Unlock' }).click();
});
await test.step('Default vault page', async () => {
await expect(page).toHaveTitle(/Vaultwarden Web/);
await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible();
});
if( mailBuffer ){
await test.step('Check email', async () => {
await mailBuffer.expect((m) => m.subject.includes("New Device Logged"));
});
}
});
}

55
playwright/tests/setups/user.ts

@ -0,0 +1,55 @@
import { expect, type Browser, Page } from '@playwright/test';
import { type MailBuffer } from 'maildev';
import * as utils from '../../global-utils';
export async function createAccount(test, page: Page, user: { email: string, name: string, password: string }, mailBuffer?: MailBuffer) {
await test.step(`Create user ${user.name}`, async () => {
await utils.cleanLanding(page);
await page.getByRole('link', { name: 'Create account' }).click();
// Back to Vault create account
await expect(page).toHaveTitle(/Create account | Vaultwarden Web/);
await page.getByLabel(/Email address/).fill(user.email);
await page.getByLabel('Name').fill(user.name);
await page.getByRole('button', { name: 'Continue' }).click();
// Vault finish Creation
await page.getByLabel('New master password (required)', { exact: true }).fill(user.password);
await page.getByLabel('Confirm new master password (').fill(user.password);
await page.getByRole('button', { name: 'Create account' }).click();
await utils.checkNotification(page, 'Your new account has been created')
// We are now in the default vault page
await expect(page).toHaveTitle('Vaults | Vaultwarden Web');
await utils.checkNotification(page, 'You have been logged in!');
if( mailBuffer ){
await mailBuffer.expect((m) => m.subject === "Welcome");
await mailBuffer.expect((m) => m.subject === "New Device Logged In From Firefox");
}
});
}
export async function logUser(test, page: Page, user: { email: string, password: string }, mailBuffer?: MailBuffer) {
await test.step(`Log user ${user.email}`, async () => {
await utils.cleanLanding(page);
await page.getByLabel(/Email address/).fill(user.email);
await page.getByRole('button', { name: 'Continue' }).click();
// Unlock page
await page.getByLabel('Master password').fill(user.password);
await page.getByRole('button', { name: 'Log in with master password' }).click();
// We are now in the default vault page
await expect(page).toHaveTitle(/Vaultwarden Web/);
if( mailBuffer ){
await mailBuffer.expect((m) => m.subject === "New Device Logged In From Firefox");
}
});
}

53
playwright/tests/sso_login.smtp.spec.ts

@ -0,0 +1,53 @@
import { test, expect, type TestInfo } from '@playwright/test';
import { MailDev } from 'maildev';
import { logNewUser, logUser } from './setups/sso';
import { activateEmail, disableEmail } from './setups/2fa';
import * as utils from "../global-utils";
let users = utils.loadEnv();
let mailserver;
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
mailserver = new MailDev({
port: process.env.MAILDEV_SMTP_PORT,
web: { port: process.env.MAILDEV_HTTP_PORT },
})
await mailserver.listen();
await utils.startVault(browser, testInfo, {
SSO_ENABLED: true,
SSO_ONLY: false,
SMTP_HOST: process.env.MAILDEV_HOST,
SMTP_FROM: process.env.PW_SMTP_FROM,
});
});
test.afterAll('Teardown', async ({}) => {
utils.stopVault();
if( mailserver ){
await mailserver.close();
}
});
test('Create and activate 2FA', async ({ page }) => {
const mailBuffer = mailserver.buffer(users.user1.email);
await logNewUser(test, page, users.user1, {mailBuffer: mailBuffer});
await activateEmail(test, page, users.user1, mailBuffer);
mailBuffer.close();
});
test('Log and disable', async ({ page }) => {
const mailBuffer = mailserver.buffer(users.user1.email);
await logUser(test, page, users.user1, {mailBuffer: mailBuffer, mail2fa: true});
await disableEmail(test, page, users.user1);
mailBuffer.close();
});

94
playwright/tests/sso_login.spec.ts

@ -0,0 +1,94 @@
import { test, expect, type TestInfo } from '@playwright/test';
import { logNewUser, logUser } from './setups/sso';
import { activateTOTP, disableTOTP } from './setups/2fa';
import * as utils from "../global-utils";
let users = utils.loadEnv();
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
await utils.startVault(browser, testInfo, {
SSO_ENABLED: true,
SSO_ONLY: false
});
});
test.afterAll('Teardown', async ({}) => {
utils.stopVault();
});
test('Account creation using SSO', async ({ page }) => {
// Landing page
await logNewUser(test, page, users.user1);
});
test('SSO login', async ({ page }) => {
await logUser(test, page, users.user1);
});
test('Non SSO login', async ({ page }) => {
// Landing page
await page.goto('/');
await page.getByLabel(/Email address/).fill(users.user1.email);
await page.getByRole('button', { name: 'Continue' }).click();
// Unlock page
await page.getByLabel('Master password').fill(users.user1.password);
await page.getByRole('button', { name: 'Log in with master password' }).click();
// We are now in the default vault page
await expect(page).toHaveTitle(/Vaultwarden Web/);
});
test('SSO login with TOTP 2fa', async ({ page }) => {
await logUser(test, page, users.user1);
let totp = await activateTOTP(test, page, users.user1);
await logUser(test, page, users.user1, { totp });
await disableTOTP(test, page, users.user1);
});
test('Non SSO login impossible', async ({ page, browser }, testInfo: TestInfo) => {
await utils.restartVault(page, testInfo, {
SSO_ENABLED: true,
SSO_ONLY: true
}, false);
// Landing page
await page.goto('/');
await page.getByLabel(/Email address/).fill(users.user1.email);
// Check that SSO login is available
await expect(page.getByRole('button', { name: /Use single sign-on/ })).toHaveCount(1);
await page.getByLabel(/Email address/).fill(users.user1.email);
await page.getByRole('button', { name: 'Continue' }).click();
// Unlock page
await page.getByLabel('Master password').fill(users.user1.password);
await page.getByRole('button', { name: 'Log in with master password' }).click();
// An error should appear
await page.getByLabel('SSO sign-in is required')
});
test('No SSO login', async ({ page }, testInfo: TestInfo) => {
await utils.restartVault(page, testInfo, {
SSO_ENABLED: false
}, false);
// Landing page
await page.goto('/');
await page.getByLabel(/Email address/).fill(users.user1.email);
// No SSO button (rely on a correct selector checked in previous test)
await page.getByLabel('Master password');
await expect(page.getByRole('button', { name: /Use single sign-on/ })).toHaveCount(0);
// Can continue to Master password
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('button', { name: /Log in with master password/ })).toHaveCount(1);
});

121
playwright/tests/sso_organization.smtp.spec.ts

@ -0,0 +1,121 @@
import { test, expect, type TestInfo } from '@playwright/test';
import { MailDev } from 'maildev';
import * as utils from "../global-utils";
import * as orgs from './setups/orgs';
import { logNewUser, logUser } from './setups/sso';
let users = utils.loadEnv();
let mailServer, mail1Buffer, mail2Buffer, mail3Buffer;
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
mailServer = new MailDev({
port: process.env.MAILDEV_SMTP_PORT,
web: { port: process.env.MAILDEV_HTTP_PORT },
})
await mailServer.listen();
await utils.startVault(browser, testInfo, {
SMTP_HOST: process.env.MAILDEV_HOST,
SMTP_FROM: process.env.PW_SMTP_FROM,
SSO_ENABLED: true,
SSO_ONLY: true,
});
mail1Buffer = mailServer.buffer(users.user1.email);
mail2Buffer = mailServer.buffer(users.user2.email);
mail3Buffer = mailServer.buffer(users.user3.email);
});
test.afterAll('Teardown', async ({}) => {
utils.stopVault();
[mail1Buffer, mail2Buffer, mail3Buffer, mailServer].map((m) => m?.close());
});
test('Create user3', async ({ page }) => {
await logNewUser(test, page, users.user3, { mailBuffer: mail3Buffer });
});
test('Invite users', async ({ page }) => {
await logNewUser(test, page, users.user1, { mailBuffer: mail1Buffer });
await orgs.create(test, page, '/Test');
await orgs.members(test, page, '/Test');
await orgs.invite(test, page, '/Test', users.user2.email);
await orgs.invite(test, page, '/Test', users.user3.email);
});
test('invited with new account', async ({ page }) => {
const link = await test.step('Extract email link', async () => {
const invited = await mail2Buffer.expect((m) => m.subject === "Join /Test");
await page.setContent(invited.html);
return await page.getByTestId("invite").getAttribute("href");
});
await test.step('Redirect to Keycloak', async () => {
await page.goto(link);
});
await test.step('Keycloak login', async () => {
await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible();
await page.getByLabel(/Username/).fill(users.user2.name);
await page.getByLabel('Password', { exact: true }).fill(users.user2.password);
await page.getByRole('button', { name: 'Sign In' }).click();
});
await test.step('Create Vault account', async () => {
await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible();
await page.getByLabel('New master password (required)', { exact: true }).fill(users.user2.password);
await page.getByLabel('Confirm new master password (').fill(users.user2.password);
await page.getByRole('button', { name: 'Create account' }).click();
});
await test.step('Default vault page', async () => {
await expect(page).toHaveTitle(/Vaultwarden Web/);
await utils.checkNotification(page, 'Account successfully created!');
await utils.checkNotification(page, 'Invitation accepted');
});
await test.step('Check mails', async () => {
await mail2Buffer.expect((m) => m.subject.includes("New Device Logged"));
await mail1Buffer.expect((m) => m.subject === "Invitation to /Test accepted");
});
});
test('invited with existing account', async ({ page }) => {
const link = await test.step('Extract email link', async () => {
const invited = await mail3Buffer.expect((m) => m.subject === "Join /Test");
await page.setContent(invited.html);
return await page.getByTestId("invite").getAttribute("href");
});
await test.step('Redirect to Keycloak', async () => {
await page.goto(link);
});
await test.step('Keycloak login', async () => {
await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible();
await page.getByLabel(/Username/).fill(users.user3.name);
await page.getByLabel('Password', { exact: true }).fill(users.user3.password);
await page.getByRole('button', { name: 'Sign In' }).click();
});
await test.step('Unlock vault', async () => {
await expect(page).toHaveTitle('Vaultwarden Web');
await page.getByLabel('Master password').fill(users.user3.password);
await page.getByRole('button', { name: 'Unlock' }).click();
});
await test.step('Default vault page', async () => {
await expect(page).toHaveTitle(/Vaultwarden Web/);
await utils.checkNotification(page, 'Invitation accepted');
});
await test.step('Check mails', async () => {
await mail3Buffer.expect((m) => m.subject.includes("New Device Logged"));
await mail1Buffer.expect((m) => m.subject === "Invitation to /Test accepted");
});
});

76
playwright/tests/sso_organization.spec.ts

@ -0,0 +1,76 @@
import { test, expect, type TestInfo } from '@playwright/test';
import { MailDev } from 'maildev';
import * as utils from "../global-utils";
import * as orgs from './setups/orgs';
import { logNewUser, logUser } from './setups/sso';
let users = utils.loadEnv();
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
await utils.startVault(browser, testInfo, {
SSO_ENABLED: true,
SSO_ONLY: true,
});
});
test.afterAll('Teardown', async ({}) => {
utils.stopVault();
});
test('Create user3', async ({ page }) => {
await logNewUser(test, page, users.user3);
});
test('Invite users', async ({ page }) => {
await logNewUser(test, page, users.user1);
await orgs.create(test, page, '/Test');
await orgs.members(test, page, '/Test');
await orgs.invite(test, page, '/Test', users.user2.email);
await orgs.invite(test, page, '/Test', users.user3.email);
await orgs.confirm(test, page, '/Test', users.user3.email);
});
test('Create invited account', async ({ page }) => {
await logNewUser(test, page, users.user2);
});
test('Confirm invited user', async ({ page }) => {
await logUser(test, page, users.user1);
await orgs.members(test, page, '/Test');
await expect(page.getByRole('row', { name: users.user2.name })).toHaveText(/Needs confirmation/);
await orgs.confirm(test, page, '/Test', users.user2.email);
});
test('Organization is visible', async ({ page }) => {
await logUser(test, page, users.user2);
await page.getByLabel('vault: /Test').click();
await expect(page.getByLabel('Filter: Default collection')).toBeVisible();
});
test('Enforce password policy', async ({ page }) => {
await logUser(test, page, users.user1);
await orgs.policies(test, page, '/Test');
await test.step(`Set master password policy`, async () => {
await page.getByRole('button', { name: 'Master password requirements' }).click();
await page.getByRole('checkbox', { name: 'Turn on' }).check();
await page.getByRole('checkbox', { name: 'Require existing members to' }).check();
await page.getByRole('spinbutton', { name: 'Minimum length' }).fill('42');
await page.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page, 'Edited policy Master password requirements.');
});
await utils.logout(test, page, users.user1);
await test.step(`Unlock trigger policy`, async () => {
await page.getByRole('textbox', { name: 'Email address (required)' }).fill(users.user1.email);
await page.getByRole('button', { name: 'Use single sign-on' }).click();
await page.getByRole('textbox', { name: 'Master password (required)' }).fill(users.user1.password);
await page.getByRole('button', { name: 'Unlock' }).click();
await expect(page.getByRole('heading', { name: 'Update master password' })).toBeVisible();
});
});

33
src/api/admin.rs

@ -46,6 +46,7 @@ pub fn routes() -> Vec<Route> {
invite_user,
logout,
delete_user,
delete_sso_user,
deauth_user,
disable_user,
enable_user,
@ -239,6 +240,7 @@ struct AdminTemplateData {
page_data: Option<Value>,
logged_in: bool,
urlpath: String,
sso_enabled: bool,
}
impl AdminTemplateData {
@ -248,6 +250,7 @@ impl AdminTemplateData {
page_data: Some(page_data),
logged_in: true,
urlpath: CONFIG.domain_path(),
sso_enabled: CONFIG.sso_enabled(),
}
}
@ -296,7 +299,7 @@ async fn invite_user(data: Json<InviteData>, _token: AdminToken, mut conn: DbCon
err_code!("User already exists", Status::Conflict.code)
}
let mut user = User::new(data.email);
let mut user = User::new(data.email, None);
async fn _generate_invite(user: &User, conn: &mut DbConn) -> EmptyResult {
if CONFIG.mail_enabled() {
@ -336,7 +339,7 @@ fn logout(cookies: &CookieJar<'_>) -> Redirect {
async fn get_users_json(_token: AdminToken, mut conn: DbConn) -> Json<Value> {
let users = User::get_all(&mut conn).await;
let mut users_json = Vec::with_capacity(users.len());
for u in users {
for (u, _) in users {
let mut usr = u.to_json(&mut conn).await;
usr["userEnabled"] = json!(u.enabled);
usr["createdAt"] = json!(format_naive_datetime_local(&u.created_at, DT_FMT));
@ -354,7 +357,7 @@ async fn get_users_json(_token: AdminToken, mut conn: DbConn) -> Json<Value> {
async fn users_overview(_token: AdminToken, mut conn: DbConn) -> ApiResult<Html<String>> {
let users = User::get_all(&mut conn).await;
let mut users_json = Vec::with_capacity(users.len());
for u in users {
for (u, sso_u) in users {
let mut usr = u.to_json(&mut conn).await;
usr["cipher_count"] = json!(Cipher::count_owned_by_user(&u.uuid, &mut conn).await);
usr["attachment_count"] = json!(Attachment::count_by_user(&u.uuid, &mut conn).await);
@ -365,6 +368,9 @@ async fn users_overview(_token: AdminToken, mut conn: DbConn) -> ApiResult<Html<
Some(dt) => json!(format_naive_datetime_local(&dt, DT_FMT)),
None => json!("Never"),
};
usr["sso_identifier"] = json!(sso_u.map(|u| u.identifier.to_string()).unwrap_or(String::new()));
users_json.push(usr);
}
@ -417,6 +423,27 @@ async fn delete_user(user_id: UserId, token: AdminToken, mut conn: DbConn) -> Em
res
}
#[delete("/users/<user_id>/sso", format = "application/json")]
async fn delete_sso_user(user_id: UserId, token: AdminToken, mut conn: DbConn) -> EmptyResult {
let memberships = Membership::find_any_state_by_user(&user_id, &mut conn).await;
let res = SsoUser::delete(&user_id, &mut conn).await;
for membership in memberships {
log_event(
EventType::OrganizationUserUnlinkedSso as i32,
&membership.uuid,
&membership.org_uuid,
&ACTING_ADMIN_USER.into(),
14, // Use UnknownBrowser type
&token.ip.ip,
&mut conn,
)
.await;
}
res
}
#[post("/users/<user_id>/deauth", format = "application/json")]
async fn deauth_user(user_id: UserId, _token: AdminToken, mut conn: DbConn, nt: Notify<'_>) -> EmptyResult {
let mut user = get_user_or_404(&user_id, &mut conn).await?;

165
src/api/core/accounts.rs

@ -7,9 +7,9 @@ use serde_json::Value;
use crate::{
api::{
core::{log_user_event, two_factor::email},
master_password_policy, register_push_device, unregister_push_device, AnonymousNotify, EmptyResult, JsonResult,
Notify, PasswordOrOtpData, UpdateType,
core::{accept_org_invite, log_user_event, two_factor::email},
master_password_policy, register_push_device, unregister_push_device, AnonymousNotify, ApiResult, EmptyResult,
JsonResult, Notify, PasswordOrOtpData, UpdateType,
},
auth::{decode_delete, decode_invite, decode_verify_email, ClientHeaders, Headers},
crypto,
@ -34,6 +34,7 @@ pub fn routes() -> Vec<rocket::Route> {
get_public_keys,
post_keys,
post_password,
post_set_password,
post_kdf,
post_rotatekey,
post_sstamp,
@ -66,15 +67,22 @@ pub fn routes() -> Vec<rocket::Route> {
]
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct KDFData {
kdf: i32,
kdf_iterations: i32,
kdf_memory: Option<i32>,
kdf_parallelism: Option<i32>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RegisterData {
email: String,
kdf: Option<i32>,
kdf_iterations: Option<i32>,
kdf_memory: Option<i32>,
kdf_parallelism: Option<i32>,
#[serde(flatten)]
kdf: KDFData,
#[serde(alias = "userSymmetricKey")]
key: String,
@ -97,6 +105,19 @@ pub struct RegisterData {
org_invite_token: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SetPasswordData {
#[serde(flatten)]
kdf: KDFData,
key: String,
keys: Option<KeysData>,
master_password_hash: String,
master_password_hint: Option<String>,
org_identifier: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct KeysData {
@ -237,10 +258,7 @@ pub async fn _register(data: Json<RegisterData>, email_verification: bool, mut c
err!("Registration email does not match invite email")
}
} else if Invitation::take(&email, &mut conn).await {
for membership in Membership::find_invited_by_user(&user.uuid, &mut conn).await.iter_mut() {
membership.status = MembershipStatus::Accepted as i32;
membership.save(&mut conn).await?;
}
Membership::accept_user_invitations(&user.uuid, &mut conn).await?;
user
} else if CONFIG.is_signup_allowed(&email)
|| (CONFIG.emergency_access_allowed()
@ -259,7 +277,7 @@ pub async fn _register(data: Json<RegisterData>, email_verification: bool, mut c
|| CONFIG.is_signup_allowed(&email)
|| pending_emergency_access.is_some()
{
User::new(email.clone())
User::new(email.clone(), None)
} else {
err!("Registration not allowed or user already exists")
}
@ -269,16 +287,7 @@ pub async fn _register(data: Json<RegisterData>, email_verification: bool, mut c
// Make sure we don't leave a lingering invitation.
Invitation::take(&email, &mut conn).await;
if let Some(client_kdf_type) = data.kdf {
user.client_kdf_type = client_kdf_type;
}
if let Some(client_kdf_iter) = data.kdf_iterations {
user.client_kdf_iter = client_kdf_iter;
}
user.client_kdf_memory = data.kdf_memory;
user.client_kdf_parallelism = data.kdf_parallelism;
set_kdf_data(&mut user, data.kdf)?;
user.set_password(&data.master_password_hash, Some(data.key), true, None);
user.password_hint = password_hint;
@ -327,6 +336,68 @@ pub async fn _register(data: Json<RegisterData>, email_verification: bool, mut c
})))
}
#[post("/accounts/set-password", data = "<data>")]
async fn post_set_password(data: Json<SetPasswordData>, 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)?;
set_kdf_data(&mut user, data.kdf)?;
user.set_password(
&data.master_password_hash,
Some(data.key),
false,
Some(vec![String::from("revision_date")]), // We need to allow revision-date to use the old security_timestamp
);
user.password_hint = password_hint;
if let Some(keys) = data.keys {
user.private_key = Some(keys.encrypted_private_key);
user.public_key = Some(keys.public_key);
}
if let Some(identifier) = data.org_identifier {
if identifier != crate::sso::FAKE_IDENTIFIER {
let org = match Organization::find_by_name(&identifier, &mut conn).await {
None => err!("Failed to retrieve the associated organization"),
Some(org) => org,
};
let membership = match Membership::find_by_user_and_org(&user.uuid, &org.uuid, &mut conn).await {
None => err!("Failed to retrieve the invitation"),
Some(org) => org,
};
accept_org_invite(&user, membership, None, &mut conn).await?;
}
}
if CONFIG.mail_enabled() {
mail::send_welcome(&user.email.to_lowercase()).await?;
} else {
Membership::accept_user_invitations(&user.uuid, &mut conn).await?;
}
log_user_event(EventType::UserChangedPassword as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &mut conn)
.await;
user.save(&mut conn).await?;
Ok(Json(json!({
"Object": "set-password",
"CaptchaBypassToken": "",
})))
}
#[get("/accounts/profile")]
async fn profile(headers: Headers, mut conn: DbConn) -> Json<Value> {
Json(headers.user.to_json(&mut conn).await)
@ -469,25 +540,15 @@ async fn post_password(data: Json<ChangePassData>, headers: Headers, mut conn: D
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ChangeKdfData {
kdf: i32,
kdf_iterations: i32,
kdf_memory: Option<i32>,
kdf_parallelism: Option<i32>,
#[serde(flatten)]
kdf: KDFData,
master_password_hash: String,
new_master_password_hash: String,
key: String,
}
#[post("/accounts/kdf", data = "<data>")]
async fn post_kdf(data: Json<ChangeKdfData>, headers: Headers, mut conn: DbConn, nt: Notify<'_>) -> EmptyResult {
let data: ChangeKdfData = data.into_inner();
let mut user = headers.user;
if !user.check_valid_password(&data.master_password_hash) {
err!("Invalid password")
}
fn set_kdf_data(user: &mut User, data: KDFData) -> EmptyResult {
if data.kdf == UserKdfType::Pbkdf2 as i32 && data.kdf_iterations < 100_000 {
err!("PBKDF2 KDF iterations must be at least 100000.")
}
@ -518,6 +579,21 @@ async fn post_kdf(data: Json<ChangeKdfData>, headers: Headers, mut conn: DbConn,
}
user.client_kdf_iter = data.kdf_iterations;
user.client_kdf_type = data.kdf;
Ok(())
}
#[post("/accounts/kdf", data = "<data>")]
async fn post_kdf(data: Json<ChangeKdfData>, headers: Headers, mut conn: DbConn, nt: Notify<'_>) -> EmptyResult {
let data: ChangeKdfData = data.into_inner();
let mut user = headers.user;
if !user.check_valid_password(&data.master_password_hash) {
err!("Invalid password")
}
set_kdf_data(&mut user, data.kdf)?;
user.set_password(&data.new_master_password_hash, Some(data.key), true, None);
let save_result = user.save(&mut conn).await;
@ -1126,15 +1202,30 @@ struct SecretVerificationRequest {
master_password_hash: String,
}
// Change the KDF Iterations if necessary
pub async fn kdf_upgrade(user: &mut User, pwd_hash: &str, conn: &mut DbConn) -> ApiResult<()> {
if user.password_iterations < CONFIG.password_iterations() {
user.password_iterations = CONFIG.password_iterations();
user.set_password(pwd_hash, None, false, None);
if let Err(e) = user.save(conn).await {
error!("Error updating user: {e:#?}");
}
}
Ok(())
}
#[post("/accounts/verify-password", data = "<data>")]
async fn verify_password(data: Json<SecretVerificationRequest>, headers: Headers, conn: DbConn) -> JsonResult {
async fn verify_password(data: Json<SecretVerificationRequest>, headers: Headers, mut conn: DbConn) -> JsonResult {
let data: SecretVerificationRequest = data.into_inner();
let user = headers.user;
let mut user = headers.user;
if !user.check_valid_password(&data.master_password_hash) {
err!("Invalid password")
}
kdf_upgrade(&mut user, &data.master_password_hash, &mut conn).await?;
Ok(Json(master_password_policy(&user, &conn).await))
}

99
src/api/core/ciphers.rs

@ -1405,7 +1405,7 @@ async fn delete_attachment_admin(
#[post("/ciphers/<cipher_id>/delete")]
async fn delete_cipher_post(cipher_id: CipherId, headers: Headers, mut conn: DbConn, nt: Notify<'_>) -> EmptyResult {
_delete_cipher_by_uuid(&cipher_id, &headers, &mut conn, false, &nt).await
_delete_cipher_by_uuid(&cipher_id, &headers, &mut conn, &CipherDeleteOptions::HardSingle, &nt).await
// permanent delete
}
@ -1416,13 +1416,13 @@ async fn delete_cipher_post_admin(
mut conn: DbConn,
nt: Notify<'_>,
) -> EmptyResult {
_delete_cipher_by_uuid(&cipher_id, &headers, &mut conn, false, &nt).await
_delete_cipher_by_uuid(&cipher_id, &headers, &mut conn, &CipherDeleteOptions::HardSingle, &nt).await
// permanent delete
}
#[put("/ciphers/<cipher_id>/delete")]
async fn delete_cipher_put(cipher_id: CipherId, headers: Headers, mut conn: DbConn, nt: Notify<'_>) -> EmptyResult {
_delete_cipher_by_uuid(&cipher_id, &headers, &mut conn, true, &nt).await
_delete_cipher_by_uuid(&cipher_id, &headers, &mut conn, &CipherDeleteOptions::SoftSingle, &nt).await
// soft delete
}
@ -1433,18 +1433,19 @@ async fn delete_cipher_put_admin(
mut conn: DbConn,
nt: Notify<'_>,
) -> EmptyResult {
_delete_cipher_by_uuid(&cipher_id, &headers, &mut conn, true, &nt).await
_delete_cipher_by_uuid(&cipher_id, &headers, &mut conn, &CipherDeleteOptions::SoftSingle, &nt).await
// soft delete
}
#[delete("/ciphers/<cipher_id>")]
async fn delete_cipher(cipher_id: CipherId, headers: Headers, mut conn: DbConn, nt: Notify<'_>) -> EmptyResult {
_delete_cipher_by_uuid(&cipher_id, &headers, &mut conn, false, &nt).await
_delete_cipher_by_uuid(&cipher_id, &headers, &mut conn, &CipherDeleteOptions::HardSingle, &nt).await
// permanent delete
}
#[delete("/ciphers/<cipher_id>/admin")]
async fn delete_cipher_admin(cipher_id: CipherId, headers: Headers, mut conn: DbConn, nt: Notify<'_>) -> EmptyResult {
_delete_cipher_by_uuid(&cipher_id, &headers, &mut conn, false, &nt).await
_delete_cipher_by_uuid(&cipher_id, &headers, &mut conn, &CipherDeleteOptions::HardSingle, &nt).await
// permanent delete
}
@ -1455,7 +1456,8 @@ async fn delete_cipher_selected(
conn: DbConn,
nt: Notify<'_>,
) -> EmptyResult {
_delete_multiple_ciphers(data, headers, conn, false, nt).await // permanent delete
_delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await
// permanent delete
}
#[post("/ciphers/delete", data = "<data>")]
@ -1465,7 +1467,8 @@ async fn delete_cipher_selected_post(
conn: DbConn,
nt: Notify<'_>,
) -> EmptyResult {
_delete_multiple_ciphers(data, headers, conn, false, nt).await // permanent delete
_delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await
// permanent delete
}
#[put("/ciphers/delete", data = "<data>")]
@ -1475,7 +1478,8 @@ async fn delete_cipher_selected_put(
conn: DbConn,
nt: Notify<'_>,
) -> EmptyResult {
_delete_multiple_ciphers(data, headers, conn, true, nt).await // soft delete
_delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::SoftMulti, nt).await
// soft delete
}
#[delete("/ciphers/admin", data = "<data>")]
@ -1485,7 +1489,8 @@ async fn delete_cipher_selected_admin(
conn: DbConn,
nt: Notify<'_>,
) -> EmptyResult {
_delete_multiple_ciphers(data, headers, conn, false, nt).await // permanent delete
_delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await
// permanent delete
}
#[post("/ciphers/delete-admin", data = "<data>")]
@ -1495,7 +1500,8 @@ async fn delete_cipher_selected_post_admin(
conn: DbConn,
nt: Notify<'_>,
) -> EmptyResult {
_delete_multiple_ciphers(data, headers, conn, false, nt).await // permanent delete
_delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await
// permanent delete
}
#[put("/ciphers/delete-admin", data = "<data>")]
@ -1505,7 +1511,8 @@ async fn delete_cipher_selected_put_admin(
conn: DbConn,
nt: Notify<'_>,
) -> EmptyResult {
_delete_multiple_ciphers(data, headers, conn, true, nt).await // soft delete
_delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::SoftMulti, nt).await
// soft delete
}
#[put("/ciphers/<cipher_id>/restore")]
@ -1659,11 +1666,19 @@ async fn delete_all(
}
}
#[derive(PartialEq)]
pub enum CipherDeleteOptions {
SoftSingle,
SoftMulti,
HardSingle,
HardMulti,
}
async fn _delete_cipher_by_uuid(
cipher_id: &CipherId,
headers: &Headers,
conn: &mut DbConn,
soft_delete: bool,
delete_options: &CipherDeleteOptions,
nt: &Notify<'_>,
) -> EmptyResult {
let Some(mut cipher) = Cipher::find_by_uuid(cipher_id, conn).await else {
@ -1674,35 +1689,42 @@ async fn _delete_cipher_by_uuid(
err!("Cipher can't be deleted by user")
}
if soft_delete {
if *delete_options == CipherDeleteOptions::SoftSingle || *delete_options == CipherDeleteOptions::SoftMulti {
cipher.deleted_at = Some(Utc::now().naive_utc());
cipher.save(conn).await?;
nt.send_cipher_update(
UpdateType::SyncCipherUpdate,
&cipher,
&cipher.update_users_revision(conn).await,
&headers.device,
None,
conn,
)
.await;
if *delete_options == CipherDeleteOptions::SoftSingle {
nt.send_cipher_update(
UpdateType::SyncCipherUpdate,
&cipher,
&cipher.update_users_revision(conn).await,
&headers.device,
None,
conn,
)
.await;
}
} else {
cipher.delete(conn).await?;
nt.send_cipher_update(
UpdateType::SyncCipherDelete,
&cipher,
&cipher.update_users_revision(conn).await,
&headers.device,
None,
conn,
)
.await;
if *delete_options == CipherDeleteOptions::HardSingle {
nt.send_cipher_update(
UpdateType::SyncLoginDelete,
&cipher,
&cipher.update_users_revision(conn).await,
&headers.device,
None,
conn,
)
.await;
}
}
if let Some(org_id) = cipher.organization_uuid {
let event_type = match soft_delete {
true => EventType::CipherSoftDeleted as i32,
false => EventType::CipherDeleted as i32,
let event_type = if *delete_options == CipherDeleteOptions::SoftSingle
|| *delete_options == CipherDeleteOptions::SoftMulti
{
EventType::CipherSoftDeleted as i32
} else {
EventType::CipherDeleted as i32
};
log_event(event_type, &cipher.uuid, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, conn)
@ -1722,17 +1744,20 @@ async fn _delete_multiple_ciphers(
data: Json<CipherIdsData>,
headers: Headers,
mut conn: DbConn,
soft_delete: bool,
delete_options: CipherDeleteOptions,
nt: Notify<'_>,
) -> EmptyResult {
let data = data.into_inner();
for cipher_id in data.ids {
if let error @ Err(_) = _delete_cipher_by_uuid(&cipher_id, &headers, &mut conn, soft_delete, &nt).await {
if let error @ Err(_) = _delete_cipher_by_uuid(&cipher_id, &headers, &mut conn, &delete_options, &nt).await {
return error;
};
}
// Multi delete actions do not send out a push for each cipher, we need to send a general sync here
nt.send_user_update(UpdateType::SyncCiphers, &headers.user, &headers.device.push_uuid, &mut conn).await;
Ok(())
}

2
src/api/core/emergency_access.rs

@ -239,7 +239,7 @@ async fn send_invite(data: Json<EmergencyAccessInviteData>, 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)
}

51
src/api/core/mod.rs

@ -50,11 +50,12 @@ pub fn events_routes() -> Vec<Route> {
use rocket::{serde::json::Json, serde::json::Value, Catcher, Route};
use crate::{
api::{JsonResult, Notify, UpdateType},
api::{EmptyResult, JsonResult, Notify, UpdateType},
auth::Headers,
db::DbConn,
db::{models::*, DbConn},
error::Error,
http_client::make_http_request,
mail,
util::parse_experimental_client_feature_flags,
};
@ -259,3 +260,49 @@ fn api_not_found() -> Json<Value> {
}
}))
}
async fn accept_org_invite(
user: &User,
mut member: Membership,
reset_password_key: Option<String>,
conn: &mut DbConn,
) -> EmptyResult {
if member.status != MembershipStatus::Invited as i32 {
err!("User already accepted the invitation");
}
// This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type
// It returns different error messages per function.
if member.atype < MembershipType::Admin {
match OrgPolicy::is_user_allowed(&member.user_uuid, &member.org_uuid, false, conn).await {
Ok(_) => {}
Err(OrgPolicyErr::TwoFactorMissing) => {
if crate::CONFIG.email_2fa_auto_fallback() {
two_factor::email::activate_email_2fa(user, conn).await?;
} else {
err!("You cannot join this organization until you enable two-step login on your user account");
}
}
Err(OrgPolicyErr::SingleOrgEnforced) => {
err!("You cannot join this organization because you are a member of an organization which forbids it");
}
}
}
member.status = MembershipStatus::Accepted as i32;
member.reset_password_key = reset_password_key;
member.save(conn).await?;
if crate::CONFIG.mail_enabled() {
let org = match Organization::find_by_uuid(&member.org_uuid, conn).await {
Some(org) => org,
None => err!("Organization not found."),
};
// User was invited to an organization, so they must be confirmed manually after acceptance
mail::send_invite_accepted(&user.email, &member.invited_by_email.unwrap_or(org.billing_email), &org.name)
.await?;
}
Ok(())
}

173
src/api/core/organizations.rs

@ -7,13 +7,13 @@ use std::collections::{HashMap, HashSet};
use crate::api::admin::FAKE_ADMIN_UUID;
use crate::{
api::{
core::{log_event, two_factor, CipherSyncData, CipherSyncType},
core::{accept_org_invite, log_event, two_factor, CipherSyncData, CipherSyncType},
EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType,
},
auth::{decode_invite, AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders},
db::{models::*, DbConn},
mail,
util::{convert_json_key_lcase_first, NumberOrString},
util::{convert_json_key_lcase_first, get_uuid, NumberOrString},
CONFIG,
};
@ -43,6 +43,7 @@ pub fn routes() -> Vec<Route> {
bulk_delete_organization_collections,
post_bulk_collections,
get_org_details,
get_org_domain_sso_verified,
get_members,
send_invite,
reinvite_member,
@ -60,6 +61,7 @@ pub fn routes() -> Vec<Route> {
post_org_import,
list_policies,
list_policies_token,
get_master_password_policy,
get_policy,
put_policy,
get_organization_tax,
@ -103,6 +105,7 @@ pub fn routes() -> Vec<Route> {
api_key,
rotate_api_key,
get_billing_metadata,
get_auto_enroll_status,
]
}
@ -192,7 +195,7 @@ async fn create_organization(headers: Headers, data: Json<OrgData>, mut conn: Db
};
let org = Organization::new(data.name, data.billing_email, private_key, public_key);
let mut member = Membership::new(headers.user.uuid, org.uuid.clone());
let mut member = Membership::new(headers.user.uuid, org.uuid.clone(), None);
let collection = Collection::new(org.uuid.clone(), data.collection_name, None);
member.akey = data.key;
@ -335,6 +338,34 @@ async fn get_user_collections(headers: Headers, mut conn: DbConn) -> Json<Value>
}))
}
// Called during the SSO enrollment
// The `identifier` should be the value returned by `get_org_domain_sso_details`
// The returned `Id` will then be passed to `get_master_password_policy` which will mainly ignore it
#[get("/organizations/<identifier>/auto-enroll-status")]
async fn get_auto_enroll_status(identifier: &str, headers: Headers, mut conn: DbConn) -> JsonResult {
let org = if identifier == crate::sso::FAKE_IDENTIFIER {
match Membership::find_main_user_org(&headers.user.uuid, &mut conn).await {
Some(member) => Organization::find_by_uuid(&member.org_uuid, &mut conn).await,
None => None,
}
} else {
Organization::find_by_name(identifier, &mut conn).await
};
let (id, identifier, rp_auto_enroll) = match org {
None => (get_uuid(), identifier.to_string(), false),
Some(org) => {
(org.uuid.to_string(), org.name, OrgPolicy::org_is_reset_password_auto_enroll(&org.uuid, &mut conn).await)
}
};
Ok(Json(json!({
"Id": id,
"Identifier": identifier,
"ResetPasswordEnabled": rp_auto_enroll,
})))
}
#[get("/organizations/<org_id>/collections")]
async fn get_org_collections(org_id: OrganizationId, headers: ManagerHeadersLoose, mut conn: DbConn) -> JsonResult {
if org_id != headers.membership.org_uuid {
@ -930,6 +961,39 @@ async fn _get_org_details(
Ok(json!(ciphers_json))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct OrgDomainDetails {
email: String,
}
// Returning a Domain/Organization here allow to prefill it and prevent prompting the user
// So we either return an Org name associated to the user or a dummy value.
// In use since `v2025.6.0`, appears to use only the first `organizationIdentifier`
#[post("/organizations/domain/sso/verified", data = "<data>")]
async fn get_org_domain_sso_verified(data: Json<OrgDomainDetails>, mut conn: DbConn) -> JsonResult {
let data: OrgDomainDetails = data.into_inner();
let identifiers = match Organization::find_org_user_email(&data.email, &mut conn)
.await
.into_iter()
.map(|o| o.name)
.collect::<Vec<String>>()
{
v if !v.is_empty() => v,
_ => vec![crate::sso::FAKE_IDENTIFIER.to_string()],
};
Ok(Json(json!({
"object": "list",
"data": identifiers.into_iter().map(|identifier| json!({
"organizationName": identifier, // appear unused
"organizationIdentifier": identifier,
"domainName": CONFIG.domain(), // appear unused
})).collect::<Vec<Value>>()
})))
}
#[derive(FromForm)]
struct GetOrgUserData {
#[field(name = "includeCollections")]
@ -1063,7 +1127,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
@ -1081,7 +1145,7 @@ async fn send_invite(
}
};
let mut new_member = Membership::new(user.uuid.clone(), org_id.clone());
let mut new_member = Membership::new(user.uuid.clone(), org_id.clone(), Some(headers.user.email.clone()));
new_member.access_all = access_all;
new_member.atype = new_type;
new_member.status = member_status;
@ -1267,71 +1331,39 @@ async fn accept_invite(
err!("Invitation was issued to a different account", "Claim does not match user_id")
}
// If a claim org_id does not match the one in from the URI, something is wrong.
if !claims.org_id.eq(&org_id) {
err!("Error accepting the invitation", "Claim does not match the org_id")
}
// If a claim does not have a member_id or it does not match the one in from the URI, something is wrong.
if !claims.member_id.eq(&member_id) {
err!("Error accepting the invitation", "Claim does not match the member_id")
}
let member = &claims.member_id;
let org = &claims.org_id;
let member_id = &claims.member_id;
Invitation::take(&claims.email, &mut conn).await;
// skip invitation logic when we were invited via the /admin panel
if **member != FAKE_ADMIN_UUID {
let Some(mut member) = Membership::find_by_uuid_and_org(member, org, &mut conn).await else {
if **member_id != FAKE_ADMIN_UUID {
let Some(mut member) = Membership::find_by_uuid_and_org(member_id, &claims.org_id, &mut conn).await else {
err!("Error accepting the invitation")
};
if member.status != MembershipStatus::Invited as i32 {
err!("User already accepted the invitation")
}
let master_password_required = OrgPolicy::org_is_reset_password_auto_enroll(org, &mut conn).await;
if data.reset_password_key.is_none() && master_password_required {
err!("Reset password key is required, but not provided.");
}
// This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type
// It returns different error messages per function.
if member.atype < MembershipType::Admin {
match OrgPolicy::is_user_allowed(&member.user_uuid, &org_id, false, &mut conn).await {
Ok(_) => {}
Err(OrgPolicyErr::TwoFactorMissing) => {
if CONFIG.email_2fa_auto_fallback() {
two_factor::email::activate_email_2fa(&headers.user, &mut conn).await?;
} else {
err!("You cannot join this organization until you enable two-step login on your user account");
}
}
Err(OrgPolicyErr::SingleOrgEnforced) => {
err!("You cannot join this organization because you are a member of an organization which forbids it");
}
}
}
member.status = MembershipStatus::Accepted as i32;
if master_password_required {
member.reset_password_key = data.reset_password_key;
}
let reset_password_key = match OrgPolicy::org_is_reset_password_auto_enroll(&member.org_uuid, &mut conn).await {
true if data.reset_password_key.is_none() => err!("Reset password key is required, but not provided."),
true => data.reset_password_key,
false => None,
};
member.save(&mut conn).await?;
}
// In case the user was invited before the mail was saved in db.
member.invited_by_email = member.invited_by_email.or(claims.invited_by_email);
if CONFIG.mail_enabled() {
if let Some(invited_by_email) = &claims.invited_by_email {
let org_name = match Organization::find_by_uuid(&claims.org_id, &mut conn).await {
Some(org) => org.name,
None => err!("Organization not found."),
};
// User was invited to an organization, so they must be confirmed manually after acceptance
mail::send_invite_accepted(&claims.email, invited_by_email, &org_name).await?;
} else {
// User was invited from /admin, so they are automatically confirmed
let org_name = CONFIG.invitation_org_name();
mail::send_invite_confirmed(&claims.email, &org_name).await?;
}
accept_org_invite(&headers.user, member, reset_password_key, &mut conn).await?;
} else if CONFIG.mail_enabled() {
// User was invited from /admin, so they are automatically confirmed
let org_name = CONFIG.invitation_org_name();
mail::send_invite_confirmed(&claims.email, &org_name).await?;
}
Ok(())
@ -2025,18 +2057,36 @@ async fn list_policies_token(org_id: OrganizationId, token: &str, mut conn: DbCo
})))
}
#[get("/organizations/<org_id>/policies/<pol_type>")]
// Called during the SSO enrollment.
// Return the org policy if it exists, otherwise use the default one.
#[get("/organizations/<org_id>/policies/master-password", rank = 1)]
async fn get_master_password_policy(org_id: OrganizationId, _headers: Headers, mut conn: DbConn) -> JsonResult {
let policy =
OrgPolicy::find_by_org_and_type(&org_id, OrgPolicyType::MasterPassword, &mut conn).await.unwrap_or_else(|| {
let data = match CONFIG.sso_master_password_policy() {
Some(policy) => policy,
None => "null".to_string(),
};
OrgPolicy::new(org_id, OrgPolicyType::MasterPassword, CONFIG.sso_master_password_policy().is_some(), data)
});
Ok(Json(policy.to_json()))
}
#[get("/organizations/<org_id>/policies/<pol_type>", 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()))
@ -2147,7 +2197,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;
@ -2306,7 +2356,8 @@ async fn import(org_id: OrganizationId, data: Json<OrgImportData>, headers: Head
MembershipStatus::Accepted as i32 // Automatically mark user as accepted if no email invites
};
let mut new_member = Membership::new(user.uuid.clone(), org_id.clone());
let mut new_member =
Membership::new(user.uuid.clone(), org_id.clone(), Some(headers.user.email.clone()));
new_member.access_all = false;
new_member.atype = MembershipType::User as i32;
new_member.status = member_status;

14
src/api/core/public.rs

@ -89,7 +89,7 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, mut conn: Db
Some(user) => user, // exists in vaultwarden
None => {
// User does not exist yet
let mut new_user = User::new(user_data.email.clone());
let mut new_user = User::new(user_data.email.clone(), None);
new_user.save(&mut conn).await?;
if !CONFIG.mail_enabled() {
@ -105,7 +105,12 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, mut conn: Db
MembershipStatus::Accepted as i32 // Automatically mark user as accepted if no email invites
};
let mut new_member = Membership::new(user.uuid.clone(), org_id.clone());
let (org_name, org_email) = match Organization::find_by_uuid(&org_id, &mut conn).await {
Some(org) => (org.name, org.billing_email),
None => err!("Error looking up organization"),
};
let mut new_member = Membership::new(user.uuid.clone(), org_id.clone(), Some(org_email.clone()));
new_member.set_external_id(Some(user_data.external_id.clone()));
new_member.access_all = false;
new_member.atype = MembershipType::User as i32;
@ -114,11 +119,6 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, mut conn: Db
new_member.save(&mut conn).await?;
if CONFIG.mail_enabled() {
let (org_name, org_email) = match Organization::find_by_uuid(&org_id, &mut conn).await {
Some(org) => (org.name, org.billing_email),
None => err!("Error looking up organization"),
};
if let Err(e) =
mail::send_invite(&user, org_id.clone(), new_member.uuid.clone(), &org_name, Some(org_email)).await
{

21
src/api/core/two_factor/email.rs

@ -10,7 +10,7 @@ use crate::{
auth::Headers,
crypto,
db::{
models::{EventType, TwoFactor, TwoFactorType, User, UserId},
models::{DeviceId, EventType, TwoFactor, TwoFactorType, User, UserId},
DbConn,
},
error::{Error, MapResult},
@ -24,11 +24,15 @@ pub fn routes() -> Vec<Route> {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct SendEmailLoginData {
// DeviceIdentifier: String, // Currently not used
device_identifier: DeviceId,
#[allow(unused)]
#[serde(alias = "Email")]
email: String,
email: Option<String>,
#[allow(unused)]
#[serde(alias = "MasterPasswordHash")]
master_password_hash: String,
master_password_hash: Option<String>,
}
/// User is trying to login and wants to use email 2FA.
@ -40,15 +44,10 @@ async fn send_email_login(data: Json<SendEmailLoginData>, mut conn: DbConn) -> E
use crate::db::models::User;
// Get the user
let Some(user) = User::find_by_mail(&data.email, &mut conn).await else {
err!("Username or password is incorrect. Try again.")
let Some(user) = User::find_by_device_id(&data.device_identifier, &mut conn).await else {
err!("Cannot find user. Try again.")
};
// Check password
if !user.check_valid_password(&data.master_password_hash) {
err!("Username or password is incorrect. Try again.")
}
if !CONFIG._enable_email_2fa() {
err!("Email 2FA is disabled")
}

487
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;
@ -11,7 +13,7 @@ use crate::api::core::two_factor::webauthn::Webauthn2FaConfig;
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},
},
@ -19,14 +21,27 @@ use crate::{
push::register_push_device,
ApiResult, EmptyResult, JsonResult,
},
auth::{generate_organization_api_key_login_claims, ClientHeaders, ClientIp, ClientVersion},
auth,
auth::{generate_organization_api_key_login_claims, AuthMethod, ClientHeaders, ClientIp, ClientVersion},
db::{models::*, DbConn},
error::MapResult,
mail, util, CONFIG,
mail, sso,
sso::{OIDCCode, OIDCState},
util, CONFIG,
};
pub fn routes() -> Vec<Route> {
routes![login, prelogin, identity_register, register_verification_email, register_finish]
routes![
login,
prelogin,
identity_register,
register_verification_email,
register_finish,
prevalidate,
authorize,
oidcsignin,
oidcsignin_error
]
}
#[post("/connect/token", data = "<data>")]
@ -44,8 +59,9 @@ async fn login(
let login_result = match data.grant_type.as_ref() {
"refresh_token" => {
_check_is_some(&data.refresh_token, "refresh_token cannot be blank")?;
_refresh_login(data, &mut conn).await
_refresh_login(data, &mut conn, &client_header.ip).await
}
"password" if CONFIG.sso_enabled() && CONFIG.sso_only() => err!("SSO sign-in is required"),
"password" => {
_check_is_some(&data.client_id, "client_id cannot be blank")?;
_check_is_some(&data.password, "password cannot be blank")?;
@ -69,6 +85,17 @@ async fn login(
_api_key_login(data, &mut user_id, &mut conn, &client_header.ip).await
}
"authorization_code" if CONFIG.sso_enabled() => {
_check_is_some(&data.client_id, "client_id cannot be blank")?;
_check_is_some(&data.code, "code cannot be blank")?;
_check_is_some(&data.device_identifier, "device_identifier cannot be blank")?;
_check_is_some(&data.device_name, "device_name cannot be blank")?;
_check_is_some(&data.device_type, "device_type cannot be blank")?;
_sso_login(data, &mut user_id, &mut conn, &client_header.ip, &client_version, webauthn).await
}
"authorization_code" => err!("SSO sign-in is not available"),
t => err!("Invalid type", t),
};
@ -102,37 +129,194 @@ async fn login(
login_result
}
async fn _refresh_login(data: ConnectData, conn: &mut DbConn) -> JsonResult {
// Return Status::Unauthorized to trigger logout
async fn _refresh_login(data: ConnectData, conn: &mut DbConn, ip: &ClientIp) -> JsonResult {
// Extract token
let token = data.refresh_token.unwrap();
// Get device by refresh token
let mut device = Device::find_by_refresh_token(&token, conn).await.map_res("Invalid refresh token")?;
let scope = "api offline_access";
let scope_vec = vec!["api".into(), "offline_access".into()];
let refresh_token = match data.refresh_token {
Some(token) => token,
None => err_code!("Missing refresh_token", Status::Unauthorized.code),
};
// Common
let user = User::find_by_uuid(&device.user_uuid, conn).await.unwrap();
// ---
// Disabled this variable, it was used to generate the JWT
// Because this might get used in the future, and is add by the Bitwarden Server, lets keep it, but then commented out
// See: https://github.com/dani-garcia/vaultwarden/issues/4156
// ---
// let members = Membership::find_confirmed_by_user(&user.uuid, conn).await;
let (access_token, expires_in) = device.refresh_tokens(&user, scope_vec, data.client_id);
device.save(conn).await?;
match auth::refresh_tokens(ip, &refresh_token, data.client_id, conn).await {
Err(err) => {
err_code!(format!("Unable to refresh login credentials: {}", err.message()), Status::Unauthorized.code)
}
Ok((mut device, auth_tokens)) => {
// Save to update `device.updated_at` to track usage and toggle new status
device.save(conn).await?;
let result = json!({
"refresh_token": auth_tokens.refresh_token(),
"access_token": auth_tokens.access_token(),
"expires_in": auth_tokens.expires_in(),
"token_type": "Bearer",
"scope": auth_tokens.scope(),
});
Ok(Json(result))
}
}
}
let result = json!({
"access_token": access_token,
"expires_in": expires_in,
"token_type": "Bearer",
"refresh_token": device.refresh_token,
// After exchanging the code we need to check first if 2FA is needed before continuing
async fn _sso_login(
data: ConnectData,
user_id: &mut Option<UserId>,
conn: &mut DbConn,
ip: &ClientIp,
client_version: &Option<ClientVersion>,
webauthn: Webauthn2FaConfig<'_>,
) -> JsonResult {
AuthMethod::Sso.check_scope(data.scope.as_ref())?;
"scope": scope,
});
// Ratelimit the login
crate::ratelimit::check_limit_login(&ip.ip)?;
Ok(Json(result))
let code = match data.code.as_ref() {
None => err!(
"Got no code in OIDC data",
ErrorEvent {
event: EventType::UserFailedLogIn
}
),
Some(code) => code,
};
let user_infos = sso::exchange_code(code, conn).await?;
let user_with_sso = match SsoUser::find_by_identifier(&user_infos.identifier, conn).await {
None => match SsoUser::find_by_mail(&user_infos.email, conn).await {
None => None,
Some((user, Some(_))) => {
error!(
"Login failure ({}), existing SSO user ({}) with same email ({})",
user_infos.identifier, user.uuid, user.email
);
err_silent!(
"Existing SSO user with same email",
ErrorEvent {
event: EventType::UserFailedLogIn
}
)
}
Some((user, None)) if user.private_key.is_some() && !CONFIG.sso_signups_match_email() => {
error!(
"Login failure ({}), existing non SSO user ({}) with same email ({}) and association is disabled",
user_infos.identifier, user.uuid, user.email
);
err_silent!(
"Existing non SSO user with same email",
ErrorEvent {
event: EventType::UserFailedLogIn
}
)
}
Some((user, None)) => Some((user, None)),
},
Some((user, sso_user)) => Some((user, Some(sso_user))),
};
let now = Utc::now().naive_utc();
// Will trigger 2FA flow if needed
let (user, mut device, twofactor_token, sso_user) = match user_with_sso {
None => {
if !CONFIG.is_email_domain_allowed(&user_infos.email) {
err!(
"Email domain not allowed",
ErrorEvent {
event: EventType::UserFailedLogIn
}
);
}
match user_infos.email_verified {
None if !CONFIG.sso_allow_unknown_email_verification() => err!(
"Your provider does not send email verification status.\n\
You will need to change the server configuration (check `SSO_ALLOW_UNKNOWN_EMAIL_VERIFICATION`) to log in.",
ErrorEvent {
event: EventType::UserFailedLogIn
}
),
Some(false) => err!(
"You need to verify your email with your provider before you can log in",
ErrorEvent {
event: EventType::UserFailedLogIn
}
),
_ => (),
}
let mut user = User::new(user_infos.email, user_infos.user_name);
user.verified_at = Some(now);
user.save(conn).await?;
let device = get_device(&data, conn, &user).await?;
(user, device, None, None)
}
Some((user, _)) if !user.enabled => {
err!(
"This user has been disabled",
format!("IP: {}. Username: {}.", ip.ip, user.name),
ErrorEvent {
event: EventType::UserFailedLogIn
}
)
}
Some((mut user, sso_user)) => {
let mut device = get_device(&data, conn, &user).await?;
let twofactor_token = twofactor_auth(&user, &data, &mut device, ip, client_version, webauthn, conn).await?;
if user.private_key.is_none() {
// User was invited a stub was created
user.verified_at = Some(now);
if let Some(user_name) = user_infos.user_name {
user.name = user_name;
}
user.save(conn).await?;
}
if user.email != user_infos.email {
if CONFIG.mail_enabled() {
mail::send_sso_change_email(&user_infos.email).await?;
}
info!("User {} email changed in SSO provider from {} to {}", user.uuid, user.email, user_infos.email);
}
(user, device, twofactor_token, sso_user)
}
};
// We passed 2FA get full user informations
let auth_user = sso::redeem(&user_infos.state, conn).await?;
if sso_user.is_none() {
let user_sso = SsoUser {
user_uuid: user.uuid.clone(),
identifier: user_infos.identifier,
};
user_sso.save(conn).await?;
}
// Set the user_uuid here to be passed back used for event logging.
*user_id = Some(user.uuid.clone());
let auth_tokens = sso::create_auth_tokens(
&device,
&user,
data.client_id,
auth_user.refresh_token,
auth_user.access_token,
auth_user.expires_in,
)?;
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, &now, conn, ip).await
}
async fn _password_login(
@ -144,11 +328,7 @@ async fn _password_login(
webauthn: Webauthn2FaConfig<'_>,
) -> 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)?;
@ -215,13 +395,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();
@ -258,12 +433,27 @@ async fn _password_login(
)
}
let (mut device, new_device) = get_device(&data, conn, &user).await;
let mut device = get_device(&data, conn, &user).await?;
let twofactor_token = twofactor_auth(&user, &data, &mut device, ip, client_version, webauthn, conn).await?;
if CONFIG.mail_enabled() && new_device {
if let Err(e) = mail::send_new_device_logged_in(&user.email, &ip.ip.to_string(), &now, &device).await {
let auth_tokens = auth::AuthTokens::new(&device, &user, AuthMethod::Password, data.client_id);
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, &now, conn, ip).await
}
#[allow(clippy::too_many_arguments)]
async fn authenticated_response(
user: &User,
device: &mut Device,
auth_tokens: auth::AuthTokens,
twofactor_token: Option<String>,
now: &NaiveDateTime,
conn: &mut DbConn,
ip: &ClientIp,
) -> JsonResult {
if CONFIG.mail_enabled() && device.is_new() {
if let Err(e) = mail::send_new_device_logged_in(&user.email, &ip.ip.to_string(), now, device).await {
error!("Error sending new device email: {e:#?}");
if CONFIG.require_device_email() {
@ -278,31 +468,21 @@ async fn _password_login(
}
// register push device
if !new_device {
register_push_device(&mut device, conn).await?;
if !device.is_new() {
register_push_device(device, conn).await?;
}
// Common
// ---
// Disabled this variable, it was used to generate the JWT
// Because this might get used in the future, and is add by the Bitwarden Server, lets keep it, but then commented out
// See: https://github.com/dani-garcia/vaultwarden/issues/4156
// ---
// let members = Membership::find_confirmed_by_user(&user.uuid, conn).await;
let (access_token, expires_in) = device.refresh_tokens(&user, scope_vec, data.client_id);
// Save to update `device.updated_at` to track usage and toggle new status
device.save(conn).await?;
let master_password_policy = master_password_policy(&user, conn).await;
let master_password_policy = master_password_policy(user, conn).await;
let mut result = json!({
"access_token": access_token,
"expires_in": expires_in,
"access_token": auth_tokens.access_token(),
"expires_in": auth_tokens.expires_in(),
"token_type": "Bearer",
"refresh_token": device.refresh_token,
"Key": user.akey,
"refresh_token": auth_tokens.refresh_token(),
"PrivateKey": user.private_key,
//"TwoFactorToken": "11122233333444555666777888999"
"Kdf": user.client_kdf_type,
"KdfIterations": user.client_kdf_iter,
"KdfMemory": user.client_kdf_memory,
@ -310,19 +490,22 @@ async fn _password_login(
"ResetMasterPassword": false, // TODO: Same as above
"ForcePasswordReset": false,
"MasterPasswordPolicy": master_password_policy,
"scope": scope,
"scope": auth_tokens.scope(),
"UserDecryptionOptions": {
"HasMasterPassword": !user.password_hash.is_empty(),
"Object": "userDecryptionOptions"
},
});
if !user.akey.is_empty() {
result["Key"] = Value::String(user.akey.clone());
}
if let Some(token) = twofactor_token {
result["TwoFactorToken"] = Value::String(token);
}
info!("User {username} logged in successfully. IP: {}", ip.ip);
info!("User {} logged in successfully. IP: {}", &user.name, ip.ip);
Ok(Json(result))
}
@ -336,9 +519,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"),
}
}
@ -385,9 +568,9 @@ async fn _user_api_key_login(
)
}
let (mut device, new_device) = get_device(&data, conn, &user).await;
let mut device = get_device(&data, conn, &user).await?;
if CONFIG.mail_enabled() && new_device {
if CONFIG.mail_enabled() && device.is_new() {
let now = Utc::now().naive_utc();
if let Err(e) = mail::send_new_device_logged_in(&user.email, &ip.ip.to_string(), &now, &device).await {
error!("Error sending new device email: {e:#?}");
@ -403,15 +586,15 @@ async fn _user_api_key_login(
}
}
// Common
let scope_vec = vec!["api".into()];
// ---
// Disabled this variable, it was used to generate the JWT
// Because this might get used in the future, and is add by the Bitwarden Server, lets keep it, but then commented out
// See: https://github.com/dani-garcia/vaultwarden/issues/4156
// ---
// let members = Membership::find_confirmed_by_user(&user.uuid, conn).await;
let (access_token, expires_in) = device.refresh_tokens(&user, scope_vec, data.client_id);
// let orgs = Membership::find_confirmed_by_user(&user.uuid, conn).await;
let access_claims = auth::LoginJwtClaims::default(&device, &user, &AuthMethod::UserApiKey, data.client_id);
// Save to update `device.updated_at` to track usage and toggle new status
device.save(conn).await?;
info!("User {} logged in successfully via API key. IP: {}", user.email, ip.ip);
@ -419,8 +602,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,
@ -430,7 +613,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))
@ -454,35 +637,29 @@ async fn _organization_api_key_login(data: ConnectData, conn: &mut DbConn, ip: &
}
let claim = generate_organization_api_key_login_claims(org_api_key.uuid, org_api_key.org_uuid);
let access_token = crate::auth::encode_jwt(&claim);
let access_token = auth::encode_jwt(&claim);
Ok(Json(json!({
"access_token": access_token,
"expires_in": 3600,
"token_type": "Bearer",
"scope": "api.organization",
"scope": AuthMethod::OrgApiKey.scope(),
})))
}
/// Retrieves an existing device or creates a new device from ConnectData and the User
async fn get_device(data: &ConnectData, conn: &mut DbConn, user: &User) -> (Device, bool) {
async fn get_device(data: &ConnectData, conn: &mut DbConn, user: &User) -> ApiResult<Device> {
// On iOS, device_type sends "iOS", on others it sends a number
// When unknown or unable to parse, return 14, which is 'Unknown Browser'
let device_type = util::try_parse_string(data.device_type.as_ref()).unwrap_or(14);
let device_id = data.device_identifier.clone().expect("No device id provided");
let device_name = data.device_name.clone().expect("No device name provided");
let mut new_device = false;
// Find device or create new
let device = match Device::find_by_uuid_and_user(&device_id, &user.uuid, conn).await {
Some(device) => device,
None => {
new_device = true;
Device::new(device_id, user.uuid.clone(), device_name, device_type)
}
};
(device, new_device)
match Device::find_by_uuid_and_user(&device_id, &user.uuid, conn).await {
Some(device) => Ok(device),
None => Device::new(device_id, user.uuid.clone(), device_name, device_type, conn).await,
}
}
async fn twofactor_auth(
@ -578,12 +755,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<TwoFactor>) -> ApiResult<String> {
@ -734,9 +912,8 @@ async fn register_verification_email(
let should_send_mail = CONFIG.mail_enabled() && CONFIG.signups_verify();
let token_claims =
crate::auth::generate_register_verify_claims(data.email.clone(), data.name.clone(), should_send_mail);
let token = crate::auth::encode_jwt(&token_claims);
let token_claims = auth::generate_register_verify_claims(data.email.clone(), data.name.clone(), should_send_mail);
let token = auth::encode_jwt(&token_claims);
if should_send_mail {
let user = User::find_by_mail(&data.email, &mut conn).await;
@ -819,11 +996,131 @@ struct ConnectData {
two_factor_remember: Option<i32>,
#[field(name = uncased("authrequest"))]
auth_request: Option<AuthRequestId>,
// Needed for authorization code
#[field(name = uncased("code"))]
code: Option<String>,
}
fn _check_is_some<T>(value: &Option<T>, msg: &str) -> EmptyResult {
if value.is_none() {
err!(msg)
}
Ok(())
}
#[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?<code>&<state>", rank = 1)]
async fn oidcsignin(code: OIDCCode, state: String, conn: DbConn) -> ApiResult<Redirect> {
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/80b74b3300e15b4ae414dc06044cc9b02b6c10a6/libs/auth/src/angular/sso/sso.component.ts#L141
#[get("/connect/oidc-signin?<state>&<error>&<error_description>", rank = 2)]
async fn oidcsignin_error(
state: String,
error: String,
error_description: Option<String>,
conn: DbConn,
) -> ApiResult<Redirect> {
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<Redirect> {
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<String>,
#[allow(unused)]
scope: Option<String>,
state: OIDCState,
#[allow(unused)]
code_challenge: Option<String>,
#[allow(unused)]
code_challenge_method: Option<String>,
#[allow(unused)]
response_mode: Option<String>,
#[allow(unused)]
domain_hint: Option<String>,
#[allow(unused)]
#[field(name = uncased("ssoToken"))]
sso_token: Option<String>,
}
// The `redirect_uri` will change depending of the client (web, android, ios ..)
#[get("/connect/authorize?<data..>")]
async fn authorize(data: AuthorizeData, conn: DbConn) -> ApiResult<Redirect> {
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)))
}

5
src/api/mod.rs

@ -36,9 +36,10 @@ use crate::db::{
models::{OrgPolicy, OrgPolicyType, User},
DbConn,
};
use crate::CONFIG;
// Type aliases for API methods results
type ApiResult<T> = Result<T, crate::error::Error>;
pub type ApiResult<T> = Result<T, crate::error::Error>;
pub type JsonResult = ApiResult<Json<Value>>;
pub type EmptyResult = ApiResult<()>;
@ -109,6 +110,8 @@ async fn master_password_policy(user: &User, conn: &DbConn) -> Value {
enforce_on_login: acc.enforce_on_login || policy.enforce_on_login,
}
}))
} else if let Some(policy_str) = CONFIG.sso_master_password_policy().filter(|_| CONFIG.sso_enabled()) {
serde_json::from_str(&policy_str).unwrap_or(json!({}))
} else {
json!({})
};

12
src/api/notifications.rs

@ -619,7 +619,7 @@ fn create_ping() -> Vec<u8> {
serialize(Value::Array(vec![6.into()]))
}
#[allow(dead_code)]
// https://github.com/bitwarden/server/blob/375af7c43b10d9da03525d41452f95de3f921541/src/Core/Enums/PushType.cs
#[derive(Copy, Clone, Eq, PartialEq)]
pub enum UpdateType {
SyncCipherUpdate = 0,
@ -632,7 +632,7 @@ pub enum UpdateType {
SyncOrgKeys = 6,
SyncFolderCreate = 7,
SyncFolderUpdate = 8,
SyncCipherDelete = 9,
// SyncCipherDelete = 9, // Redirects to `SyncLoginDelete` on upstream
SyncSettings = 10,
LogOut = 11,
@ -644,6 +644,14 @@ pub enum UpdateType {
AuthRequest = 15,
AuthRequestResponse = 16,
// SyncOrganizations = 17, // Not supported
// SyncOrganizationStatusChanged = 18, // Not supported
// SyncOrganizationCollectionSettingChanged = 19, // Not supported
// Notification = 20, // Not supported
// NotificationStatus = 21, // Not supported
// RefreshSecurityTasks = 22, // Not supported
None = 100,
}

12
src/api/web.rs

@ -55,13 +55,15 @@ fn not_found() -> ApiResult<Html<String>> {
#[get("/css/vaultwarden.css")]
fn vaultwarden_css() -> Cached<Css<String>> {
let css_options = json!({
"signup_disabled": CONFIG.is_signup_disabled(),
"mail_enabled": CONFIG.mail_enabled(),
"mail_2fa_enabled": CONFIG._enable_email_2fa(),
"yubico_enabled": CONFIG._enable_yubico() && CONFIG.yubico_client_id().is_some() && CONFIG.yubico_secret_key().is_some(),
"emergency_access_allowed": CONFIG.emergency_access_allowed(),
"sends_allowed": CONFIG.sends_allowed(),
"load_user_scss": true,
"mail_2fa_enabled": CONFIG._enable_email_2fa(),
"mail_enabled": CONFIG.mail_enabled(),
"sends_allowed": CONFIG.sends_allowed(),
"signup_disabled": CONFIG.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) {

254
src/auth.rs

@ -1,6 +1,5 @@
// JWT Handling
//
use chrono::{TimeDelta, Utc};
use chrono::{DateTime, TimeDelta, Utc};
use jsonwebtoken::{errors::ErrorKind, Algorithm, DecodingKey, EncodingKey, Header};
use num_traits::FromPrimitive;
use once_cell::sync::{Lazy, OnceCell};
@ -10,17 +9,24 @@ use serde::ser::Serialize;
use std::{env, net::IpAddr};
use crate::{
api::ApiResult,
config::PathType,
db::models::{
AttachmentId, CipherId, CollectionId, DeviceId, EmergencyAccessId, MembershipId, OrgApiKeyId, OrganizationId,
SendFileId, SendId, UserId,
AttachmentId, CipherId, CollectionId, DeviceId, DeviceType, EmergencyAccessId, MembershipId, OrgApiKeyId,
OrganizationId, SendFileId, SendId, UserId,
},
error::Error,
sso, CONFIG,
};
use crate::{error::Error, CONFIG};
const JWT_ALGORITHM: Algorithm = Algorithm::RS256;
pub static DEFAULT_VALIDITY: Lazy<TimeDelta> = Lazy::new(|| TimeDelta::try_hours(2).unwrap());
// Limit when BitWarden consider the token as expired
pub static BW_EXPIRATION: Lazy<TimeDelta> = Lazy::new(|| TimeDelta::try_minutes(5).unwrap());
pub static DEFAULT_REFRESH_VALIDITY: Lazy<TimeDelta> = Lazy::new(|| TimeDelta::try_days(30).unwrap());
pub static MOBILE_REFRESH_VALIDITY: Lazy<TimeDelta> = Lazy::new(|| TimeDelta::try_days(90).unwrap());
pub static DEFAULT_ACCESS_VALIDITY: Lazy<TimeDelta> = Lazy::new(|| TimeDelta::try_hours(2).unwrap());
static JWT_HEADER: Lazy<Header> = Lazy::new(|| Header::new(JWT_ALGORITHM));
pub static JWT_LOGIN_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|login", CONFIG.domain_origin()));
@ -85,7 +91,7 @@ pub fn encode_jwt<T: Serialize>(claims: &T) -> String {
}
}
fn decode_jwt<T: DeserializeOwned>(token: &str, issuer: String) -> Result<T, Error> {
pub fn decode_jwt<T: DeserializeOwned>(token: &str, issuer: String) -> Result<T, Error> {
let mut validation = jsonwebtoken::Validation::new(JWT_ALGORITHM);
validation.leeway = 30; // 30 seconds
validation.validate_exp = true;
@ -99,11 +105,15 @@ fn decode_jwt<T: DeserializeOwned>(token: &str, issuer: String) -> Result<T, Err
ErrorKind::InvalidToken => err!("Token is invalid"),
ErrorKind::InvalidIssuer => err!("Issuer is invalid"),
ErrorKind::ExpiredSignature => err!("Token has expired"),
_ => err!("Error decoding JWT"),
_ => err!(format!("Error decoding JWT: {:?}", err)),
},
}
}
pub fn decode_refresh(token: &str) -> Result<RefreshJwtClaims, Error> {
decode_jwt(token, JWT_LOGIN_ISSUER.to_string())
}
pub fn decode_login(token: &str) -> Result<LoginJwtClaims, Error> {
decode_jwt(token, JWT_LOGIN_ISSUER.to_string())
}
@ -186,6 +196,84 @@ pub struct LoginJwtClaims {
pub amr: Vec<String>,
}
impl LoginJwtClaims {
pub fn new(
device: &Device,
user: &User,
nbf: i64,
exp: i64,
scope: Vec<String>,
client_id: Option<String>,
now: DateTime<Utc>,
) -> 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<super::UserOrganization>,
// ---
// let orgowner: Vec<_> = orgs.iter().filter(|o| o.atype == 0).map(|o| o.org_uuid.clone()).collect();
// let orgadmin: Vec<_> = orgs.iter().filter(|o| o.atype == 1).map(|o| o.org_uuid.clone()).collect();
// let orguser: Vec<_> = orgs.iter().filter(|o| o.atype == 2).map(|o| o.org_uuid.clone()).collect();
// let orgmanager: Vec<_> = orgs.iter().filter(|o| o.atype == 3).map(|o| o.org_uuid.clone()).collect();
if exp <= (now + *BW_EXPIRATION).timestamp() {
warn!("Raise access_token lifetime to more than 5min.")
}
// Create the JWT claims struct, to send to the client
Self {
nbf,
exp,
iss: JWT_LOGIN_ISSUER.to_string(),
sub: user.uuid.clone(),
premium: true,
name: user.name.clone(),
email: user.email.clone(),
email_verified: !CONFIG.mail_enabled() || user.verified_at.is_some(),
// ---
// Disabled these keys to be added to the JWT since they could cause the JWT to get too large
// Also These key/value pairs are not used anywhere by either Vaultwarden or Bitwarden Clients
// Because these might get used in the future, and they are added by the Bitwarden Server, lets keep it, but then commented out
// See: https://github.com/dani-garcia/vaultwarden/issues/4156
// ---
// orgowner,
// orgadmin,
// orguser,
// orgmanager,
sstamp: user.security_stamp.clone(),
device: device.uuid.clone(),
devicetype: DeviceType::from_i32(device.atype).to_string(),
client_id: client_id.unwrap_or("undefined".to_string()),
scope,
amr: vec!["Application".into()],
}
}
pub fn default(device: &Device, user: &User, auth_method: &AuthMethod, client_id: Option<String>) -> Self {
let time_now = Utc::now();
Self::new(
device,
user,
time_now.timestamp(),
(time_now + *DEFAULT_ACCESS_VALIDITY).timestamp(),
auth_method.scope_vec(),
client_id,
time_now,
)
}
pub fn token(&self) -> String {
encode_jwt(&self)
}
pub fn expires_in(&self) -> i64 {
self.exp - Utc::now().timestamp()
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct InviteJwtClaims {
// Not before
@ -1001,3 +1089,153 @@ impl<'r> FromRequest<'r> for ClientVersion {
Outcome::Success(ClientVersion(version))
}
}
#[derive(Clone, Debug, Ord, PartialOrd, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AuthMethod {
OrgApiKey,
Password,
Sso,
UserApiKey,
}
impl AuthMethod {
pub fn scope(&self) -> String {
match self {
AuthMethod::OrgApiKey => "api.organization".to_string(),
AuthMethod::Password => "api offline_access".to_string(),
AuthMethod::Sso => "api offline_access".to_string(),
AuthMethod::UserApiKey => "api".to_string(),
}
}
pub fn scope_vec(&self) -> Vec<String> {
self.scope().split_whitespace().map(str::to_string).collect()
}
pub fn check_scope(&self, scope: Option<&String>) -> ApiResult<String> {
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<TokenWrapper>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct AuthTokens {
pub refresh_claims: RefreshJwtClaims,
pub access_claims: LoginJwtClaims,
}
impl AuthTokens {
pub fn refresh_token(&self) -> String {
encode_jwt(&self.refresh_claims)
}
pub fn access_token(&self) -> String {
self.access_claims.token()
}
pub fn expires_in(&self) -> i64 {
self.access_claims.expires_in()
}
pub fn scope(&self) -> String {
self.refresh_claims.sub.scope()
}
// Create refresh_token and access_token with default validity
pub fn new(device: &Device, user: &User, sub: AuthMethod, client_id: Option<String>) -> Self {
let time_now = Utc::now();
let access_claims = LoginJwtClaims::default(device, user, &sub, client_id);
let validity = if DeviceType::is_mobile(&device.atype) {
*MOBILE_REFRESH_VALIDITY
} else {
*DEFAULT_REFRESH_VALIDITY
};
let refresh_claims = RefreshJwtClaims {
nbf: time_now.timestamp(),
exp: (time_now + validity).timestamp(),
iss: JWT_LOGIN_ISSUER.to_string(),
sub,
device_token: device.refresh_token.clone(),
token: None,
};
Self {
refresh_claims,
access_claims,
}
}
}
pub async fn refresh_tokens(
ip: &ClientIp,
refresh_token: &str,
client_id: Option<String>,
conn: &mut DbConn,
) -> ApiResult<(Device, AuthTokens)> {
let refresh_claims = match decode_refresh(refresh_token) {
Err(err) => {
debug!("Failed to decode {} refresh_token: {refresh_token}", ip.ip);
err_silent!(format!("Impossible to read refresh_token: {}", err.message()))
}
Ok(claims) => claims,
};
// Get device by refresh token
let mut device = match Device::find_by_refresh_token(&refresh_claims.device_token, conn).await {
None => err!("Invalid refresh token"),
Some(device) => device,
};
// Save to update `updated_at`.
device.save(conn).await?;
let user = match User::find_by_uuid(&device.user_uuid, conn).await {
None => err!("Impossible to find user"),
Some(user) => user,
};
let auth_tokens = match refresh_claims.sub {
AuthMethod::Sso if CONFIG.sso_enabled() && CONFIG.sso_auth_only_not_session() => {
AuthTokens::new(&device, &user, refresh_claims.sub, client_id)
}
AuthMethod::Sso if CONFIG.sso_enabled() => {
sso::exchange_refresh_token(&device, &user, client_id, refresh_claims).await?
}
AuthMethod::Sso => err!("SSO is now disabled, Login again using email and master password"),
AuthMethod::Password if CONFIG.sso_enabled() && CONFIG.sso_only() => err!("SSO is now required, Login again"),
AuthMethod::Password => AuthTokens::new(&device, &user, refresh_claims.sub, client_id),
_ => err!("Invalid auth method, cannot refresh token"),
};
Ok((device, auth_tokens))
}

102
src/config.rs

@ -458,6 +458,9 @@ make_config! {
/// Duo Auth context cleanup schedule |> Cron schedule of the job that cleans expired Duo contexts from the database. Does nothing if Duo MFA is disabled or set to use the legacy iframe prompt.
/// Defaults to once every minute. Set blank to disable this job.
duo_context_purge_schedule: String, false, def, "30 * * * * *".to_string();
/// Purge incomplete SSO nonce. |> Cron schedule of the job that cleans leftover nonce in db due to incomplete SSO login.
/// Defaults to daily. Set blank to disable this job.
purge_incomplete_sso_nonce: String, false, def, "0 20 0 * * *".to_string();
},
/// General settings
@ -676,6 +679,42 @@ make_config! {
enforce_single_org_with_reset_pw_policy: bool, false, def, false;
},
/// OpenID Connect SSO settings
sso {
/// Enabled
sso_enabled: bool, true, 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, true, def, String::new();
/// Client Key
sso_client_secret: Pass, true, def, String::new();
/// Authority Server |> Base url of the OIDC provider discovery endpoint (without `/.well-known/openid-configuration`)
sso_authority: String, true, def, String::new();
/// Authorization request scopes |> List the of the needed scope (`openid` is implicit)
sso_scopes: String, true, def, "email profile".to_string();
/// Authorization request extra parameters
sso_authorize_extra_params: String, true, def, String::new();
/// Use PKCE during Authorization flow
sso_pkce: bool, true, def, true;
/// Regex for additional trusted Id token audience |> By default only the client_id is trusted.
sso_audience_trusted: String, true, option;
/// CallBack Path |> Generated from Domain.
sso_callback_path: String, true, generated, |c| generate_sso_callback_path(&c.domain);
/// Optional SSO master password policy |> Ex format: '{"enforceOnLogin":false,"minComplexity":3,"minLength":12,"requireLower":false,"requireNumbers":false,"requireSpecial":false,"requireUpper":false}'
sso_master_password_policy: String, true, option;
/// Use SSO only for auth not the session lifecycle |> Use default Vaultwarden session lifecycle (Idle refresh token valid for 30days)
sso_auth_only_not_session: bool, true, def, false;
/// Client cache for discovery endpoint. |> Duration in seconds (0 or less to disable). More details: https://github.com/dani-garcia/vaultwarden/blob/sso-support/SSO.md#client-cache
sso_client_cache_expiration: u64, true, def, 0;
/// Log all tokens |> `LOG_LEVEL=debug` or `LOG_LEVEL=info,vaultwarden::sso=debug` is required
sso_debug_tokens: bool, true, def, false;
},
/// Yubikey settings
yubico: _enable_yubico {
/// Enabled
@ -911,6 +950,16 @@ 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")
}
validate_internal_sso_issuer_url(&cfg.sso_authority)?;
validate_internal_sso_redirect_url(&cfg.sso_callback_path)?;
check_master_password_policy(&cfg.sso_master_password_policy)?;
}
if cfg._enable_yubico {
if cfg.yubico_client_id.is_some() != cfg.yubico_secret_key.is_some() {
err!("Both `YUBICO_CLIENT_ID` and `YUBICO_SECRET_KEY` must be set for Yubikey OTP support")
@ -1088,6 +1137,28 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> {
Ok(())
}
fn validate_internal_sso_issuer_url(sso_authority: &String) -> Result<openidconnect::IssuerUrl, Error> {
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 validate_internal_sso_redirect_url(sso_callback_path: &String) -> Result<openidconnect::RedirectUrl, Error> {
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 check_master_password_policy(sso_master_password_policy: &Option<String>) -> Result<(), Error> {
let policy = sso_master_password_policy.as_ref().map(|mpp| serde_json::from_str::<serde_json::Value>(mpp));
if let Some(Err(error)) = policy {
err!(format!("Invalid sso_master_password_policy ({error}), Ensure that it's correctly escaped with ''"))
}
Ok(())
}
/// Extracts an RFC 6454 web origin from a URL.
fn extract_url_origin(url: &str) -> String {
match Url::parse(url) {
@ -1119,6 +1190,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 {
@ -1354,12 +1429,14 @@ impl Config {
}
}
// The registration link should be hidden if signup is not allowed and whitelist is empty
// unless mail is disabled and invitations are allowed
// The registration link should be hidden if
// - Signup is not allowed and email whitelist is empty unless mail is disabled and invitations are allowed
// - The SSO is activated and password login is disabled.
pub fn is_signup_disabled(&self) -> bool {
!self.signups_allowed()
(!self.signups_allowed()
&& self.signups_domains_whitelist().is_empty()
&& (self.mail_enabled() || !self.invitations_allowed())
&& (self.mail_enabled() || !self.invitations_allowed()))
|| (self.sso_enabled() && self.sso_only())
}
/// Tests whether the specified user is allowed to create an organization.
@ -1475,6 +1552,22 @@ impl Config {
}
}
}
pub fn sso_issuer_url(&self) -> Result<openidconnect::IssuerUrl, Error> {
validate_internal_sso_issuer_url(&self.sso_authority())
}
pub fn sso_redirect_url(&self) -> Result<openidconnect::RedirectUrl, Error> {
validate_internal_sso_redirect_url(&self.sso_callback_path())
}
pub fn sso_scopes_vec(&self) -> Vec<String> {
self.sso_scopes().split_whitespace().map(str::to_string).collect()
}
pub fn sso_authorize_extra_params_vec(&self) -> Vec<(String, String)> {
url::form_urlencoded::parse(self.sso_authorize_extra_params().as_bytes()).into_owned().collect()
}
}
use handlebars::{
@ -1540,6 +1633,7 @@ where
reg!("email/send_org_invite", ".html");
reg!("email/send_single_org_removed_from_org", ".html");
reg!("email/smtp_test", ".html");
reg!("email/sso_change_email", ".html");
reg!("email/twofactor_email", ".html");
reg!("email/verify_email", ".html");
reg!("email/welcome_must_verify", ".html");

132
src/db/models/device.rs

@ -1,4 +1,6 @@
use chrono::{NaiveDateTime, Utc};
use data_encoding::{BASE64, BASE64URL};
use derive_more::{Display, From};
use serde_json::Value;
@ -6,7 +8,6 @@ use super::{AuthRequest, UserId};
use crate::{
crypto,
util::{format_date, get_uuid},
CONFIG,
};
use macros::{IdFromParam, UuidFromParam};
@ -34,25 +35,6 @@ db_object! {
/// Local methods
impl Device {
pub fn new(uuid: DeviceId, user_uuid: UserId, name: String, atype: i32) -> Self {
let now = Utc::now().naive_utc();
Self {
uuid,
created_at: now,
updated_at: now,
user_uuid,
name,
atype,
push_uuid: Some(PushId(get_uuid())),
push_token: None,
refresh_token: String::new(),
twofactor_remember: None,
}
}
pub fn to_json(&self) -> Value {
json!({
"id": self.uuid,
@ -66,7 +48,6 @@ impl Device {
}
pub fn refresh_twofactor_remember(&mut self) -> String {
use data_encoding::BASE64;
let twofactor_remember = crypto::encode_random_bytes::<180>(BASE64);
self.twofactor_remember = Some(twofactor_remember.clone());
@ -77,71 +58,9 @@ impl Device {
self.twofactor_remember = None;
}
pub fn refresh_tokens(
&mut self,
user: &super::User,
scope: Vec<String>,
client_id: Option<String>,
) -> (String, i64) {
// If there is no refresh token, we create one
if self.refresh_token.is_empty() {
use data_encoding::BASE64URL;
self.refresh_token = crypto::encode_random_bytes::<64>(BASE64URL);
}
// Update the expiration of the device and the last update date
let time_now = Utc::now();
self.updated_at = time_now.naive_utc();
// Generate a random push_uuid so if it doesn't already have one
if self.push_uuid.is_none() {
self.push_uuid = Some(PushId(get_uuid()));
}
// ---
// Disabled these keys to be added to the JWT since they could cause the JWT to get too large
// Also These key/value pairs are not used anywhere by either Vaultwarden or Bitwarden Clients
// Because these might get used in the future, and they are added by the Bitwarden Server, lets keep it, but then commented out
// ---
// fn arg: members: Vec<super::Membership>,
// ---
// let orgowner: Vec<_> = members.iter().filter(|m| m.atype == 0).map(|o| o.org_uuid.clone()).collect();
// let orgadmin: Vec<_> = members.iter().filter(|m| m.atype == 1).map(|o| o.org_uuid.clone()).collect();
// let orguser: Vec<_> = members.iter().filter(|m| m.atype == 2).map(|o| o.org_uuid.clone()).collect();
// let orgmanager: Vec<_> = members.iter().filter(|m| m.atype == 3).map(|o| o.org_uuid.clone()).collect();
// Create the JWT claims struct, to send to the client
use crate::auth::{encode_jwt, LoginJwtClaims, DEFAULT_VALIDITY, JWT_LOGIN_ISSUER};
let claims = LoginJwtClaims {
nbf: time_now.timestamp(),
exp: (time_now + *DEFAULT_VALIDITY).timestamp(),
iss: JWT_LOGIN_ISSUER.to_string(),
sub: user.uuid.clone(),
premium: true,
name: user.name.clone(),
email: user.email.clone(),
email_verified: !CONFIG.mail_enabled() || user.verified_at.is_some(),
// ---
// Disabled these keys to be added to the JWT since they could cause the JWT to get too large
// Also These key/value pairs are not used anywhere by either Vaultwarden or Bitwarden Clients
// Because these might get used in the future, and they are added by the Bitwarden Server, lets keep it, but then commented out
// See: https://github.com/dani-garcia/vaultwarden/issues/4156
// ---
// orgowner,
// orgadmin,
// orguser,
// orgmanager,
sstamp: user.security_stamp.clone(),
device: self.uuid.clone(),
devicetype: DeviceType::from_i32(self.atype).to_string(),
client_id: client_id.unwrap_or("undefined".to_string()),
scope,
amr: vec!["Application".into()],
};
(encode_jwt(&claims), DEFAULT_VALIDITY.num_seconds())
// This rely on the fact we only update the device after a successful login
pub fn is_new(&self) -> bool {
self.created_at == self.updated_at
}
pub fn is_push_device(&self) -> bool {
@ -187,14 +106,39 @@ impl DeviceWithAuthRequest {
}
use crate::db::DbConn;
use crate::api::EmptyResult;
use crate::api::{ApiResult, EmptyResult};
use crate::error::MapResult;
/// Database methods
impl Device {
pub async fn save(&mut self, conn: &mut DbConn) -> EmptyResult {
self.updated_at = Utc::now().naive_utc();
pub async fn new(
uuid: DeviceId,
user_uuid: UserId,
name: String,
atype: i32,
conn: &mut DbConn,
) -> ApiResult<Device> {
let now = Utc::now().naive_utc();
let device = Self {
uuid,
created_at: now,
updated_at: now,
user_uuid,
name,
atype,
push_uuid: Some(PushId(get_uuid())),
push_token: None,
refresh_token: crypto::encode_random_bytes::<64>(BASE64URL),
twofactor_remember: None,
};
device.inner_save(conn).await.map(|()| device)
}
async fn inner_save(&self, conn: &mut DbConn) -> EmptyResult {
db_run! { conn:
sqlite, mysql {
crate::util::retry(
@ -212,6 +156,12 @@ impl Device {
}
}
// Should only be called after user has passed authentication
pub async fn save(&mut self, conn: &mut DbConn) -> EmptyResult {
self.updated_at = Utc::now().naive_utc();
self.inner_save(conn).await
}
pub async fn delete_all_by_user(user_uuid: &UserId, conn: &mut DbConn) -> EmptyResult {
db_run! { conn: {
diesel::delete(devices::table.filter(devices::user_uuid.eq(user_uuid)))
@ -403,6 +353,10 @@ impl DeviceType {
_ => DeviceType::UnknownBrowser,
}
}
pub fn is_mobile(value: &i32) -> bool {
*value == DeviceType::Android as i32 || *value == DeviceType::Ios as i32
}
}
#[derive(

2
src/db/models/event.rs

@ -89,7 +89,7 @@ pub enum EventType {
OrganizationUserUpdated = 1502,
OrganizationUserRemoved = 1503, // Organization user data was deleted
OrganizationUserUpdatedGroups = 1504,
// OrganizationUserUnlinkedSso = 1505, // Not supported
OrganizationUserUnlinkedSso = 1505,
OrganizationUserResetPasswordEnroll = 1506,
OrganizationUserResetPasswordWithdraw = 1507,
OrganizationUserAdminResetPassword = 1508,

4
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};

4
src/db/models/org_policy.rs

@ -67,12 +67,12 @@ pub enum OrgPolicyErr {
/// Local methods
impl OrgPolicy {
pub fn new(org_uuid: OrganizationId, atype: OrgPolicyType, data: String) -> Self {
pub fn new(org_uuid: OrganizationId, atype: OrgPolicyType, enabled: bool, data: String) -> Self {
Self {
uuid: OrgPolicyId(crate::util::get_uuid()),
org_uuid,
atype: atype as i32,
enabled: false,
enabled,
data,
}
}

71
src/db/models/organization.rs

@ -36,6 +36,8 @@ db_object! {
pub user_uuid: UserId,
pub org_uuid: OrganizationId,
pub invited_by_email: Option<String>,
pub access_all: bool,
pub akey: String,
pub status: i32,
@ -235,12 +237,13 @@ impl Organization {
const ACTIVATE_REVOKE_DIFF: i32 = 128;
impl Membership {
pub fn new(user_uuid: UserId, org_uuid: OrganizationId) -> Self {
pub fn new(user_uuid: UserId, org_uuid: OrganizationId, invited_by_email: Option<String>) -> Self {
Self {
uuid: MembershipId(crate::util::get_uuid()),
user_uuid,
org_uuid,
invited_by_email,
access_all: false,
akey: String::new(),
@ -389,11 +392,53 @@ impl Organization {
}}
}
pub async fn find_by_name(name: &str, conn: &mut DbConn) -> Option<Self> {
db_run! { conn: {
organizations::table
.filter(organizations::name.eq(name))
.first::<OrganizationDb>(conn)
.ok().from_db()
}}
}
pub async fn get_all(conn: &mut DbConn) -> Vec<Self> {
db_run! { conn: {
organizations::table.load::<OrganizationDb>(conn).expect("Error loading organizations").from_db()
}}
}
pub async fn find_main_org_user_email(user_email: &str, conn: &mut DbConn) -> Option<Organization> {
let lower_mail = user_email.to_lowercase();
db_run! { conn: {
organizations::table
.inner_join(users_organizations::table.on(users_organizations::org_uuid.eq(organizations::uuid)))
.inner_join(users::table.on(users::uuid.eq(users_organizations::user_uuid)))
.filter(users::email.eq(lower_mail))
.filter(users_organizations::status.ne(MembershipStatus::Revoked as i32))
.order(users_organizations::atype.asc())
.select(organizations::all_columns)
.first::<OrganizationDb>(conn)
.ok().from_db()
}}
}
pub async fn find_org_user_email(user_email: &str, conn: &mut DbConn) -> Vec<Organization> {
let lower_mail = user_email.to_lowercase();
db_run! { conn: {
organizations::table
.inner_join(users_organizations::table.on(users_organizations::org_uuid.eq(organizations::uuid)))
.inner_join(users::table.on(users::uuid.eq(users_organizations::user_uuid)))
.filter(users::email.eq(lower_mail))
.filter(users_organizations::status.ne(MembershipStatus::Revoked as i32))
.order(users_organizations::atype.asc())
.select(organizations::all_columns)
.load::<OrganizationDb>(conn)
.expect("Error loading user orgs")
.from_db()
}}
}
}
impl Membership {
@ -827,6 +872,19 @@ impl Membership {
}}
}
// Should be used only when email are disabled.
// In Organizations::send_invite status is set to Accepted only if the user has a password.
pub async fn accept_user_invitations(user_uuid: &UserId, conn: &mut DbConn) -> EmptyResult {
db_run! { conn: {
diesel::update(users_organizations::table)
.filter(users_organizations::user_uuid.eq(user_uuid))
.filter(users_organizations::status.eq(MembershipStatus::Invited as i32))
.set(users_organizations::status.eq(MembershipStatus::Accepted as i32))
.execute(conn)
.map_res("Error confirming invitations")
}}
}
pub async fn find_any_state_by_user(user_uuid: &UserId, conn: &mut DbConn) -> Vec<Self> {
db_run! { conn: {
users_organizations::table
@ -1103,6 +1161,17 @@ impl Membership {
.first::<MembershipDb>(conn).ok().from_db()
}}
}
pub async fn find_main_user_org(user_uuid: &str, conn: &mut DbConn) -> Option<Self> {
db_run! { conn: {
users_organizations::table
.filter(users_organizations::user_uuid.eq(user_uuid))
.filter(users_organizations::status.ne(MembershipStatus::Revoked as i32))
.order(users_organizations::atype.asc())
.first::<MembershipDb>(conn)
.ok().from_db()
}}
}
}
impl OrganizationApiKey {

89
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<String>,
pub redirect_uri: String,
pub created_at: NaiveDateTime,
}
}
/// Local methods
impl SsoNonce {
pub fn new(state: OIDCState, nonce: String, verifier: Option<String>, 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<Self> {
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::<SsoNonceDb>(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")
}
}
}

93
src/db/models/user.rs

@ -8,15 +8,17 @@ use super::{
use crate::{
api::EmptyResult,
crypto,
db::models::DeviceId,
db::DbConn,
error::MapResult,
sso::OIDCIdentifier,
util::{format_date, get_uuid, retry},
CONFIG,
};
use macros::UuidFromParam;
db_object! {
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
#[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)]
#[diesel(table_name = users)]
#[diesel(treat_none_as_null = true)]
#[diesel(primary_key(uuid))]
@ -71,6 +73,14 @@ db_object! {
pub struct Invitation {
pub email: String,
}
#[derive(Identifiable, Queryable, Insertable, Selectable)]
#[diesel(table_name = sso_users)]
#[diesel(primary_key(user_uuid))]
pub struct SsoUser {
pub user_uuid: UserId,
pub identifier: OIDCIdentifier,
}
}
pub enum UserKdfType {
@ -96,7 +106,7 @@ impl User {
pub const CLIENT_KDF_TYPE_DEFAULT: i32 = UserKdfType::Pbkdf2 as i32;
pub const CLIENT_KDF_ITER_DEFAULT: i32 = 600_000;
pub fn new(email: String) -> Self {
pub fn new(email: String, name: Option<String>) -> Self {
let now = Utc::now().naive_utc();
let email = email.to_lowercase();
@ -108,7 +118,7 @@ impl User {
verified_at: None,
last_verifying_at: None,
login_verify_count: 0,
name: email.clone(),
name: name.unwrap_or(email.clone()),
email,
akey: String::new(),
email_new: None,
@ -384,9 +394,28 @@ impl User {
}}
}
pub async fn get_all(conn: &mut DbConn) -> Vec<Self> {
pub async fn find_by_device_id(device_uuid: &DeviceId, conn: &mut DbConn) -> Option<Self> {
db_run! { conn: {
users::table
.inner_join(devices::table.on(devices::user_uuid.eq(users::uuid)))
.filter(devices::uuid.eq(device_uuid))
.select(users::all_columns)
.first::<UserDb>(conn)
.ok()
.from_db()
}}
}
pub async fn get_all(conn: &mut DbConn) -> Vec<(User, Option<SsoUser>)> {
db_run! {conn: {
users::table.load::<UserDb>(conn).expect("Error loading users").from_db()
users::table
.left_join(sso_users::table)
.select(<(UserDb, Option<SsoUserDb>)>::as_select())
.load(conn)
.expect("Error loading groups for user")
.into_iter()
.map(|(user, sso_user)| { (user.from_db(), sso_user.from_db()) })
.collect()
}}
}
@ -477,3 +506,57 @@ impl Invitation {
#[deref(forward)]
#[from(forward)]
pub struct UserId(String);
impl SsoUser {
pub async fn save(&self, conn: &mut DbConn) -> EmptyResult {
db_run! { conn:
sqlite, mysql {
diesel::replace_into(sso_users::table)
.values(SsoUserDb::to_db(self))
.execute(conn)
.map_res("Error saving SSO user")
}
postgresql {
let value = SsoUserDb::to_db(self);
diesel::insert_into(sso_users::table)
.values(&value)
.execute(conn)
.map_res("Error saving SSO user")
}
}
}
pub async fn find_by_identifier(identifier: &str, conn: &DbConn) -> Option<(User, SsoUser)> {
db_run! {conn: {
users::table
.inner_join(sso_users::table)
.select(<(UserDb, SsoUserDb)>::as_select())
.filter(sso_users::identifier.eq(identifier))
.first::<(UserDb, SsoUserDb)>(conn)
.ok()
.map(|(user, sso_user)| { (user.from_db(), sso_user.from_db()) })
}}
}
pub async fn find_by_mail(mail: &str, conn: &DbConn) -> Option<(User, Option<SsoUser>)> {
let lower_mail = mail.to_lowercase();
db_run! {conn: {
users::table
.left_join(sso_users::table)
.select(<(UserDb, Option<SsoUserDb>)>::as_select())
.filter(users::email.eq(lower_mail))
.first::<(UserDb, Option<SsoUserDb>)>(conn)
.ok()
.map(|(user, sso_user)| { (user.from_db(), sso_user.from_db()) })
}}
}
pub async fn delete(user_uuid: &UserId, conn: &mut DbConn) -> EmptyResult {
db_run! {conn: {
diesel::delete(sso_users::table.filter(sso_users::user_uuid.eq(user_uuid)))
.execute(conn)
.map_res("Error deleting sso user")
}}
}
}

20
src/db/schemas/mysql/schema.rs

@ -235,6 +235,7 @@ table! {
uuid -> Text,
user_uuid -> Text,
org_uuid -> Text,
invited_by_email -> Nullable<Text>,
access_all -> Bool,
akey -> Text,
status -> Integer,
@ -254,6 +255,23 @@ table! {
}
}
table! {
sso_nonce (state) {
state -> Text,
nonce -> Text,
verifier -> Nullable<Text>,
redirect_uri -> Text,
created_at -> Timestamp,
}
}
table! {
sso_users (user_uuid) {
user_uuid -> Text,
identifier -> Text,
}
}
table! {
emergency_access (uuid) {
uuid -> Text,
@ -348,6 +366,7 @@ joinable!(collections_groups -> collections (collections_uuid));
joinable!(collections_groups -> groups (groups_uuid));
joinable!(event -> users_organizations (uuid));
joinable!(auth_requests -> users (user_uuid));
joinable!(sso_users -> users (user_uuid));
allow_tables_to_appear_in_same_query!(
attachments,
@ -361,6 +380,7 @@ allow_tables_to_appear_in_same_query!(
org_policies,
organizations,
sends,
sso_users,
twofactor,
users,
users_collections,

20
src/db/schemas/postgresql/schema.rs

@ -235,6 +235,7 @@ table! {
uuid -> Text,
user_uuid -> Text,
org_uuid -> Text,
invited_by_email -> Nullable<Text>,
access_all -> Bool,
akey -> Text,
status -> Integer,
@ -254,6 +255,23 @@ table! {
}
}
table! {
sso_nonce (state) {
state -> Text,
nonce -> Text,
verifier -> Nullable<Text>,
redirect_uri -> Text,
created_at -> Timestamp,
}
}
table! {
sso_users (user_uuid) {
user_uuid -> Text,
identifier -> Text,
}
}
table! {
emergency_access (uuid) {
uuid -> Text,
@ -348,6 +366,7 @@ joinable!(collections_groups -> collections (collections_uuid));
joinable!(collections_groups -> groups (groups_uuid));
joinable!(event -> users_organizations (uuid));
joinable!(auth_requests -> users (user_uuid));
joinable!(sso_users -> users (user_uuid));
allow_tables_to_appear_in_same_query!(
attachments,
@ -361,6 +380,7 @@ allow_tables_to_appear_in_same_query!(
org_policies,
organizations,
sends,
sso_users,
twofactor,
users,
users_collections,

20
src/db/schemas/sqlite/schema.rs

@ -235,6 +235,7 @@ table! {
uuid -> Text,
user_uuid -> Text,
org_uuid -> Text,
invited_by_email -> Nullable<Text>,
access_all -> Bool,
akey -> Text,
status -> Integer,
@ -254,6 +255,23 @@ table! {
}
}
table! {
sso_nonce (state) {
state -> Text,
nonce -> Text,
verifier -> Nullable<Text>,
redirect_uri -> Text,
created_at -> Timestamp,
}
}
table! {
sso_users (user_uuid) {
user_uuid -> Text,
identifier -> Text,
}
}
table! {
emergency_access (uuid) {
uuid -> Text,
@ -348,6 +366,7 @@ joinable!(collections_groups -> collections (collections_uuid));
joinable!(collections_groups -> groups (groups_uuid));
joinable!(event -> users_organizations (uuid));
joinable!(auth_requests -> users (user_uuid));
joinable!(sso_users -> users (user_uuid));
allow_tables_to_appear_in_same_query!(
attachments,
@ -361,6 +380,7 @@ allow_tables_to_appear_in_same_query!(
org_policies,
organizations,
sends,
sso_users,
twofactor,
users,
users_collections,

10
src/error.rs

@ -159,6 +159,10 @@ impl Error {
pub fn get_event(&self) -> &Option<ErrorEvent> {
&self.event
}
pub fn message(&self) -> &str {
&self.message
}
}
pub trait MapResult<S> {
@ -278,9 +282,15 @@ macro_rules! err_silent {
($msg:expr) => {{
return Err($crate::error::Error::new($msg, $msg));
}};
($msg:expr, ErrorEvent $err_event:tt) => {{
return Err($crate::error::Error::new($msg, $msg).with_event($crate::error::ErrorEvent $err_event));
}};
($usr_msg:expr, $log_value:expr) => {{
return Err($crate::error::Error::new($usr_msg, $log_value));
}};
($usr_msg:expr, $log_value:expr, ErrorEvent $err_event:tt) => {{
return Err($crate::error::Error::new($usr_msg, $log_value).with_event($crate::error::ErrorEvent $err_event));
}};
}
#[macro_export]

18
src/mail.rs

@ -301,7 +301,11 @@ pub async fn send_invite(
.append_pair("organizationId", &org_id)
.append_pair("organizationUserId", &member_id)
.append_pair("token", &invite_token);
if user.private_key.is_some() {
if CONFIG.sso_enabled() && CONFIG.sso_only() {
query_params.append_pair("orgUserHasExistingUser", "false");
query_params.append_pair("orgSsoIdentifier", org_name);
} else if user.private_key.is_some() {
query_params.append_pair("orgUserHasExistingUser", "true");
}
}
@ -584,6 +588,18 @@ pub async fn send_change_email_existing(address: &str, acting_address: &str) ->
send_email(address, &subject, body_html, body_text).await
}
pub async fn send_sso_change_email(address: &str) -> EmptyResult {
let (subject, body_html, body_text) = get_text(
"email/sso_change_email",
json!({
"url": format!("{}/#/settings/account", CONFIG.domain()),
"img_src": CONFIG._smtp_img_src(),
}),
)?;
send_email(address, &subject, body_html, body_text).await
}
pub async fn send_test(address: &str) -> EmptyResult {
let (subject, body_html, body_text) = get_text(
"email/smtp_test",

9
src/main.rs

@ -56,6 +56,8 @@ mod db;
mod http_client;
mod mail;
mod ratelimit;
mod sso;
mod sso_client;
mod util;
use crate::api::core::two_factor::duo_oidc::purge_duo_contexts;
@ -714,6 +716,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

462
src/sso.rs

@ -0,0 +1,462 @@
use chrono::Utc;
use derive_more::{AsRef, Deref, Display, From};
use regex::Regex;
use std::time::Duration;
use url::Url;
use mini_moka::sync::Cache;
use once_cell::sync::Lazy;
use crate::{
api::ApiResult,
auth,
auth::{AuthMethod, AuthTokens, TokenWrapper, BW_EXPIRATION, DEFAULT_REFRESH_VALIDITY},
db::{
models::{Device, SsoNonce, User},
DbConn,
},
sso_client::Client,
CONFIG,
};
pub static FAKE_IDENTIFIER: &str = "Vaultwarden";
static AC_CACHE: Lazy<Cache<OIDCState, AuthenticatedUser>> =
Lazy::new(|| Cache::builder().max_capacity(1000).time_to_live(Duration::from_secs(10 * 60)).build());
static SSO_JWT_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|sso", CONFIG.domain_origin()));
pub static NONCE_EXPIRATION: Lazy<chrono::Duration> = Lazy::new(|| chrono::TimeDelta::try_minutes(10).unwrap());
#[derive(
Clone,
Debug,
Default,
DieselNewType,
FromForm,
PartialEq,
Eq,
Hash,
Serialize,
Deserialize,
AsRef,
Deref,
Display,
From,
)]
#[deref(forward)]
#[from(forward)]
pub struct OIDCCode(String);
#[derive(
Clone,
Debug,
Default,
DieselNewType,
FromForm,
PartialEq,
Eq,
Hash,
Serialize,
Deserialize,
AsRef,
Deref,
Display,
From,
)]
#[deref(forward)]
#[from(forward)]
pub struct OIDCState(String);
#[derive(Debug, Serialize, Deserialize)]
struct SsoTokenJwtClaims {
// Not before
pub nbf: i64,
// Expiration time
pub exp: i64,
// Issuer
pub iss: String,
// Subject
pub sub: String,
}
pub fn encode_ssotoken_claims() -> String {
let time_now = Utc::now();
let claims = SsoTokenJwtClaims {
nbf: time_now.timestamp(),
exp: (time_now + chrono::TimeDelta::try_minutes(2).unwrap()).timestamp(),
iss: SSO_JWT_ISSUER.to_string(),
sub: "vaultwarden".to_string(),
};
auth::encode_jwt(&claims)
}
#[derive(Debug, Serialize, Deserialize)]
pub enum OIDCCodeWrapper {
Ok {
state: OIDCState,
code: OIDCCode,
},
Error {
state: OIDCState,
error: String,
error_description: Option<String>,
},
}
#[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<i64>,
nbf: Option<i64>,
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<BasicTokenClaims> {
let mut validation = jsonwebtoken::Validation::default();
validation.set_issuer(&[CONFIG.sso_authority()]);
validation.insecure_disable_signature_validation();
validation.validate_aud = false;
match jsonwebtoken::decode(token, &jsonwebtoken::DecodingKey::from_secret(&[]), &validation) {
Ok(btc) => Ok(btc.claims),
Err(err) => err_silent!(format!("Failed to decode basic token claims from {token_name}: {err}")),
}
}
pub fn deocde_state(base64_state: String) -> ApiResult<OIDCState> {
let state = match data_encoding::BASE64.decode(base64_state.as_bytes()) {
Ok(vec) => match String::from_utf8(vec) {
Ok(valid) => OIDCState(valid),
Err(_) => err!(format!("Invalid utf8 chars in {base64_state} after base64 decoding")),
},
Err(_) => err!(format!("Failed to decode {base64_state} using base64")),
};
Ok(state)
}
// The `nonce` allow to protect against replay attacks
// redirect_uri from: https://github.com/bitwarden/server/blob/main/src/Identity/IdentityServer/ApiClient.cs
pub async fn authorize_url(
state: OIDCState,
client_id: &str,
raw_redirect_uri: &str,
mut conn: DbConn,
) -> ApiResult<Url> {
let redirect_uri = match client_id {
"web" | "browser" => format!("{}/sso-connector.html", CONFIG.domain()),
"desktop" | "mobile" => "bitwarden://sso-callback".to_string(),
"cli" => {
let port_regex = Regex::new(r"^http://localhost:([0-9]{4})$").unwrap();
match port_regex.captures(raw_redirect_uri).and_then(|captures| captures.get(1).map(|c| c.as_str())) {
Some(port) => format!("http://localhost:{port}"),
None => err!("Failed to extract port number"),
}
}
_ => err!(format!("Unsupported client {client_id}")),
};
let (auth_url, nonce) = Client::authorize_url(state, redirect_uri).await?;
nonce.save(&mut conn).await?;
Ok(auth_url)
}
#[derive(
Clone,
Debug,
Default,
DieselNewType,
FromForm,
PartialEq,
Eq,
Hash,
Serialize,
Deserialize,
AsRef,
Deref,
Display,
From,
)]
#[deref(forward)]
#[from(forward)]
pub struct OIDCIdentifier(String);
impl OIDCIdentifier {
fn new(issuer: &str, subject: &str) -> Self {
OIDCIdentifier(format!("{issuer}/{subject}"))
}
}
#[derive(Clone, Debug)]
pub struct AuthenticatedUser {
pub refresh_token: Option<String>,
pub access_token: String,
pub expires_in: Option<Duration>,
pub identifier: OIDCIdentifier,
pub email: String,
pub email_verified: Option<bool>,
pub user_name: Option<String>,
}
#[derive(Clone, Debug)]
pub struct UserInformation {
pub state: OIDCState,
pub identifier: OIDCIdentifier,
pub email: String,
pub email_verified: Option<bool>,
pub user_name: Option<String>,
}
async fn decode_code_claims(code: &str, conn: &mut DbConn) -> ApiResult<(OIDCCode, OIDCState)> {
match auth::decode_jwt::<OIDCCodeClaims>(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<UserInformation> {
use openidconnect::OAuth2TokenResponse;
let (code, state) = decode_code_claims(wrapped_code, conn).await?;
if let Some(authenticated_user) = AC_CACHE.get(&state) {
return Ok(UserInformation {
state,
identifier: authenticated_user.identifier,
email: authenticated_user.email,
email_verified: authenticated_user.email_verified,
user_name: authenticated_user.user_name,
});
}
let nonce = match SsoNonce::find(&state, conn).await {
None => err!(format!("Invalid state cannot retrieve nonce")),
Some(nonce) => nonce,
};
let client = Client::cached().await?;
let (token_response, id_claims) = client.exchange_code(code, nonce).await?;
let user_info = client.user_info(token_response.access_token().to_owned()).await?;
let email = match id_claims.email().or(user_info.email()) {
None => err!("Neither id token nor userinfo contained an email"),
Some(e) => e.to_string().to_lowercase(),
};
let email_verified = id_claims.email_verified().or(user_info.email_verified());
let user_name = id_claims.preferred_username().map(|un| un.to_string());
let refresh_token = token_response.refresh_token().map(|t| t.secret());
if refresh_token.is_none() && CONFIG.sso_scopes_vec().contains(&"offline_access".to_string()) {
error!("Scope offline_access is present but response contain no refresh_token");
}
let identifier = OIDCIdentifier::new(id_claims.issuer(), id_claims.subject());
let authenticated_user = AuthenticatedUser {
refresh_token: refresh_token.cloned(),
access_token: token_response.access_token().secret().clone(),
expires_in: token_response.expires_in(),
identifier: identifier.clone(),
email: email.clone(),
email_verified,
user_name: user_name.clone(),
};
debug!("Authentified user {authenticated_user:?}");
AC_CACHE.insert(state.clone(), authenticated_user);
Ok(UserInformation {
state,
identifier,
email,
email_verified,
user_name,
})
}
// User has passed 2FA flow we can delete `nonce` and clear the cache.
pub async fn redeem(state: &OIDCState, conn: &mut DbConn) -> ApiResult<AuthenticatedUser> {
if let Err(err) = SsoNonce::delete(state, conn).await {
error!("Failed to delete database sso_nonce using {state}: {err}")
}
if let Some(au) = AC_CACHE.get(state) {
AC_CACHE.invalidate(state);
Ok(au)
} else {
err!("Failed to retrieve user info from sso cache")
}
}
// We always return a refresh_token (with no refresh_token some secrets are not displayed in the web front).
// If there is no SSO refresh_token, we keep the access_token to be able to call user_info to check for validity
pub fn create_auth_tokens(
device: &Device,
user: &User,
client_id: Option<String>,
refresh_token: Option<String>,
access_token: String,
expires_in: Option<Duration>,
) -> ApiResult<AuthTokens> {
if !CONFIG.sso_auth_only_not_session() {
let now = Utc::now();
let (ap_nbf, ap_exp) = match (decode_token_claims("access_token", &access_token), expires_in) {
(Ok(ap), _) => (ap.nbf(), ap.exp),
(Err(_), Some(exp)) => (now.timestamp(), (now + exp).timestamp()),
_ => err!("Non jwt access_token and empty expires_in"),
};
let access_claims =
auth::LoginJwtClaims::new(device, user, ap_nbf, ap_exp, AuthMethod::Sso.scope_vec(), client_id, now);
_create_auth_tokens(device, refresh_token, access_claims, access_token)
} else {
Ok(AuthTokens::new(device, user, AuthMethod::Sso, client_id))
}
}
fn _create_auth_tokens(
device: &Device,
refresh_token: Option<String>,
access_claims: auth::LoginJwtClaims,
access_token: String,
) -> ApiResult<AuthTokens> {
let (nbf, exp, token) = if let Some(rt) = refresh_token {
match decode_token_claims("refresh_token", &rt) {
Err(_) => {
let time_now = Utc::now();
let exp = (time_now + *DEFAULT_REFRESH_VALIDITY).timestamp();
debug!("Non jwt refresh_token (expiration set to {exp})");
(time_now.timestamp(), exp, TokenWrapper::Refresh(rt))
}
Ok(refresh_payload) => {
debug!("Refresh_payload: {refresh_payload:?}");
(refresh_payload.nbf(), refresh_payload.exp, TokenWrapper::Refresh(rt))
}
}
} else {
debug!("No refresh_token present");
(access_claims.nbf, access_claims.exp, TokenWrapper::Access(access_token))
};
let refresh_claims = auth::RefreshJwtClaims {
nbf,
exp,
iss: auth::JWT_LOGIN_ISSUER.to_string(),
sub: AuthMethod::Sso,
device_token: device.refresh_token.clone(),
token: Some(token),
};
Ok(AuthTokens {
refresh_claims,
access_claims,
})
}
// This endpoint is called in two case
// - the session is close to expiration we will try to extend it
// - the user is going to make an action and we check that the session is still valid
pub async fn exchange_refresh_token(
device: &Device,
user: &User,
client_id: Option<String>,
refresh_claims: auth::RefreshJwtClaims,
) -> ApiResult<AuthTokens> {
let exp = refresh_claims.exp;
match refresh_claims.token {
Some(TokenWrapper::Refresh(refresh_token)) => {
// Use new refresh_token if returned
let (new_refresh_token, access_token, expires_in) =
Client::exchange_refresh_token(refresh_token.clone()).await?;
create_auth_tokens(
device,
user,
client_id,
new_refresh_token.or(Some(refresh_token)),
access_token,
expires_in,
)
}
Some(TokenWrapper::Access(access_token)) => {
let now = Utc::now();
let exp_limit = (now + *BW_EXPIRATION).timestamp();
if exp < exp_limit {
err_silent!("Access token is close to expiration but we have no refresh token")
}
Client::check_validaty(access_token.clone()).await?;
let access_claims = auth::LoginJwtClaims::new(
device,
user,
now.timestamp(),
exp,
AuthMethod::Sso.scope_vec(),
client_id,
now,
);
_create_auth_tokens(device, None, access_claims, access_token)
}
None => err!("No token present while in SSO"),
}
}

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save