Explorar o código

SSO using OpenID Connect (#3899)

* Add SSO functionality using OpenID Connect

Co-authored-by: Pablo Ovelleiro Corral <[email protected]>
Co-authored-by: Stuart Heap <[email protected]>
Co-authored-by: Alex Moore <[email protected]>
Co-authored-by: Brian Munro <[email protected]>
Co-authored-by: Jacques B. <[email protected]>

* Improvements and error handling

* Stop rolling device token

* Add playwright tests

* Activate PKCE by default

* Ensure result order when searching for sso_user

* add SSO_ALLOW_UNKNOWN_EMAIL_VERIFICATION

* Toggle SSO button in scss

* Base64 encode state before sending it to providers

* Prevent disabled User from SSO login

* Review fixes

* Remove unused UserOrganization.invited_by_email

* Split SsoUser::find_by_identifier_or_email

* api::Accounts::verify_password add the policy even if it's ignored

* Disable signups if SSO_ONLY is activated

* Add verifiedDate to organizations::get_org_domain_sso_details

* Review fixes

* Remove OrganizationId guard from get_master_password_policy

* Add wrapper type OIDCCode OIDCState OIDCIdentifier

* Membership::confirm_user_invitations fix and tests

* Allow set-password only if account is unitialized

* Review fixes

* Prevent accepting another user invitation

* Log password change event on SSO account creation

* Unify master password policy resolution

* Upgrade openidconnect to 4.0.0

* Revert "Remove unused UserOrganization.invited_by_email"

This reverts commit 548e19995e141314af98a10d170ea7371f02fab4.

* Process org enrollment in accounts::post_set_password

* Improve tests

* Pass the claim invited_by_email in case it was not in db

* Add Slack configuration hints

* Fix playwright tests

* Skip broken tests

* Add sso identifier in admin user panel

* Remove duplicate expiration check, add a log

* Augment mobile refresh_token validity

* Rauthy configuration hints

* Fix playwright tests

* Playwright upgrade and conf improvement

* Playwright tests improvements

* 2FA email and device creation change

* Fix and improve Playwright tests

* Minor improvements

* Fix enforceOnLogin org policies

* Run playwright sso tests against correct db

* PKCE should now work with Zitadel

* Playwright upgrade maildev to use MailBuffer.expect

* Upgrades playwright tests deps

* Check email_verified in id_token and user_info

* Add sso verified endpoint for v2025.6.0

* Fix playwright tests

* Create a separate sso_client

* Upgrade openidconnect to 4.0.1

* Server settings for login fields toggle

* Use only css for login fields

* Fix playwright test

* Review fix

* More review fix

* Perform same checks when setting kdf

---------

Co-authored-by: Felix Eckhofer <[email protected]>
Co-authored-by: Pablo Ovelleiro Corral <[email protected]>
Co-authored-by: Stuart Heap <[email protected]>
Co-authored-by: Alex Moore <[email protected]>
Co-authored-by: Brian Munro <[email protected]>
Co-authored-by: Jacques B. <[email protected]>
Co-authored-by: Timshel <timshel@480s>
Timshel hai 2 meses
pai
achega
cff6c2b3af
Modificáronse 100 ficheiros con 7984 adicións e 318 borrados
  1. 53 0
      .env.template
  2. 562 10
      Cargo.lock
  3. 8 0
      Cargo.toml
  4. 303 0
      SSO.md
  5. 1 0
      migrations/mysql/2023-09-10-133000_add_sso/down.sql
  6. 4 0
      migrations/mysql/2023-09-10-133000_add_sso/up.sql
  7. 1 0
      migrations/mysql/2023-09-14-133000_add_users_organizations_invited_by_email/down.sql
  8. 1 0
      migrations/mysql/2023-09-14-133000_add_users_organizations_invited_by_email/up.sql
  9. 6 0
      migrations/mysql/2024-02-14-170000_add_state_to_sso_nonce/down.sql
  10. 8 0
      migrations/mysql/2024-02-14-170000_add_state_to_sso_nonce/up.sql
  11. 8 0
      migrations/mysql/2024-02-26-170000_add_pkce_to_sso_nonce/down.sql
  12. 9 0
      migrations/mysql/2024-02-26-170000_add_pkce_to_sso_nonce/up.sql
  13. 1 0
      migrations/mysql/2024-03-06-170000_add_sso_users/down.sql
  14. 7 0
      migrations/mysql/2024-03-06-170000_add_sso_users/up.sql
  15. 0 0
      migrations/mysql/2024-03-13-170000_sso_users_cascade/down.sql
  16. 2 0
      migrations/mysql/2024-03-13-170000_sso_users_cascade/up.sql
  17. 1 0
      migrations/postgresql/2023-09-10-133000_add_sso/down.sql
  18. 4 0
      migrations/postgresql/2023-09-10-133000_add_sso/up.sql
  19. 1 0
      migrations/postgresql/2023-09-14-133000_add_users_organizations_invited_by_email/down.sql
  20. 1 0
      migrations/postgresql/2023-09-14-133000_add_users_organizations_invited_by_email/up.sql
  21. 6 0
      migrations/postgresql/2024-02-14-170000_add_state_to_sso_nonce/down.sql
  22. 8 0
      migrations/postgresql/2024-02-14-170000_add_state_to_sso_nonce/up.sql
  23. 8 0
      migrations/postgresql/2024-02-26-170000_add_pkce_to_sso_nonce/down.sql
  24. 9 0
      migrations/postgresql/2024-02-26-170000_add_pkce_to_sso_nonce/up.sql
  25. 1 0
      migrations/postgresql/2024-03-06-170000_add_sso_users/down.sql
  26. 7 0
      migrations/postgresql/2024-03-06-170000_add_sso_users/up.sql
  27. 0 0
      migrations/postgresql/2024-03-13-170000_sso_users_cascade/down.sql
  28. 3 0
      migrations/postgresql/2024-03-13-170000_sso_users_cascade/up.sql
  29. 1 0
      migrations/sqlite/2023-09-10-133000_add_sso/down.sql
  30. 4 0
      migrations/sqlite/2023-09-10-133000_add_sso/up.sql
  31. 1 0
      migrations/sqlite/2023-09-14-133000_add_users_organizations_invited_by_email/down.sql
  32. 1 0
      migrations/sqlite/2023-09-14-133000_add_users_organizations_invited_by_email/up.sql
  33. 6 0
      migrations/sqlite/2024-02-14-170000_add_state_to_sso_nonce/down.sql
  34. 8 0
      migrations/sqlite/2024-02-14-170000_add_state_to_sso_nonce/up.sql
  35. 8 0
      migrations/sqlite/2024-02-26-170000_add_pkce_to_sso_nonce/down.sql
  36. 9 0
      migrations/sqlite/2024-02-26-170000_add_pkce_to_sso_nonce/up.sql
  37. 1 0
      migrations/sqlite/2024-03-06-170000_add_sso_users/down.sql
  38. 7 0
      migrations/sqlite/2024-03-06-170000_add_sso_users/up.sql
  39. 0 0
      migrations/sqlite/2024-03-13_170000_sso_userscascade/down.sql
  40. 9 0
      migrations/sqlite/2024-03-13_170000_sso_userscascade/up.sql
  41. 64 0
      playwright/.env.template
  42. 6 0
      playwright/.gitignore
  43. 166 0
      playwright/README.md
  44. 40 0
      playwright/compose/keycloak/Dockerfile
  45. 36 0
      playwright/compose/keycloak/setup.sh
  46. 40 0
      playwright/compose/playwright/Dockerfile
  47. 40 0
      playwright/compose/warden/Dockerfile
  48. 24 0
      playwright/compose/warden/build.sh
  49. 124 0
      playwright/docker-compose.yml
  50. 22 0
      playwright/global-setup.ts
  51. 246 0
      playwright/global-utils.ts
  52. 2547 0
      playwright/package-lock.json
  53. 21 0
      playwright/package.json
  54. 143 0
      playwright/playwright.config.ts
  55. 93 0
      playwright/test.env
  56. 37 0
      playwright/tests/collection.spec.ts
  57. 100 0
      playwright/tests/login.smtp.spec.ts
  58. 51 0
      playwright/tests/login.spec.ts
  59. 115 0
      playwright/tests/organization.smtp.spec.ts
  60. 54 0
      playwright/tests/organization.spec.ts
  61. 92 0
      playwright/tests/setups/2fa.ts
  62. 7 0
      playwright/tests/setups/db-setup.ts
  63. 11 0
      playwright/tests/setups/db-teardown.ts
  64. 9 0
      playwright/tests/setups/db-test.ts
  65. 77 0
      playwright/tests/setups/orgs.ts
  66. 18 0
      playwright/tests/setups/sso-setup.ts
  67. 15 0
      playwright/tests/setups/sso-teardown.ts
  68. 138 0
      playwright/tests/setups/sso.ts
  69. 55 0
      playwright/tests/setups/user.ts
  70. 53 0
      playwright/tests/sso_login.smtp.spec.ts
  71. 94 0
      playwright/tests/sso_login.spec.ts
  72. 121 0
      playwright/tests/sso_organization.smtp.spec.ts
  73. 76 0
      playwright/tests/sso_organization.spec.ts
  74. 30 3
      src/api/admin.rs
  75. 98 10
      src/api/core/accounts.rs
  76. 1 1
      src/api/core/emergency_access.rs
  77. 49 2
      src/api/core/mod.rs
  78. 112 61
      src/api/core/organizations.rs
  79. 7 7
      src/api/core/public.rs
  80. 10 11
      src/api/core/two_factor/email.rs
  81. 391 95
      src/api/identity.rs
  82. 4 1
      src/api/mod.rs
  83. 7 5
      src/api/web.rs
  84. 246 8
      src/auth.rs
  85. 98 4
      src/config.rs
  86. 43 89
      src/db/models/device.rs
  87. 1 1
      src/db/models/event.rs
  88. 3 1
      src/db/models/mod.rs
  89. 2 2
      src/db/models/org_policy.rs
  90. 70 1
      src/db/models/organization.rs
  91. 89 0
      src/db/models/sso_nonce.rs
  92. 88 5
      src/db/models/user.rs
  93. 20 0
      src/db/schemas/mysql/schema.rs
  94. 20 0
      src/db/schemas/postgresql/schema.rs
  95. 20 0
      src/db/schemas/sqlite/schema.rs
  96. 10 0
      src/error.rs
  97. 17 1
      src/mail.rs
  98. 9 0
      src/main.rs
  99. 462 0
      src/sso.rs
  100. 264 0
      src/sso_client.rs

+ 53 - 0
.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 ###
 ########################

+ 562 - 10
Cargo.lock

@@ -655,6 +655,12 @@ dependencies = [
  "windows-targets 0.52.6",
 ]
 
+[[package]]
+name = "base16ct"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
+
 [[package]]
 name = "base64"
 version = "0.13.1"
@@ -781,6 +787,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"
@@ -845,6 +857,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"
@@ -1092,6 +1135,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"
@@ -1102,6 +1157,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"
@@ -1137,6 +1219,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"
@@ -1181,6 +1276,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e"
 dependencies = [
  "powerfmt",
+ "serde",
 ]
 
 [[package]]
@@ -1407,12 +1503,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"
@@ -1475,6 +1636,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"
@@ -1520,6 +1690,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"
@@ -1723,6 +1909,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
 dependencies = [
  "typenum",
  "version_check",
+ "zeroize",
 ]
 
 [[package]]
@@ -1783,7 +1970,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",
@@ -1806,12 +1993,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"
@@ -1824,7 +2022,7 @@ dependencies = [
  "futures-core",
  "futures-sink",
  "http 1.3.1",
- "indexmap",
+ "indexmap 2.10.0",
  "slab",
  "tokio",
  "tokio-util",
@@ -1854,6 +2052,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"
@@ -1939,6 +2143,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"
@@ -2106,6 +2319,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"
@@ -2269,6 +2498,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"
@@ -2346,6 +2586,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"
@@ -2636,6 +2885,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"
@@ -2711,6 +2975,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"
@@ -2854,6 +3135,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"
@@ -2901,6 +3202,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"
@@ -2955,6 +3287,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"
@@ -2977,6 +3318,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"
@@ -3318,6 +3683,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"
@@ -3365,6 +3739,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"
@@ -3695,10 +4080,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",
@@ -3710,6 +4097,7 @@ dependencies = [
  "serde_urlencoded",
  "sync_wrapper",
  "tokio",
+ "tokio-native-tls",
  "tokio-rustls 0.26.2",
  "tokio-util",
  "tower",
@@ -3729,6 +4117,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"
@@ -3778,7 +4176,7 @@ dependencies = [
  "either",
  "figment",
  "futures",
- "indexmap",
+ "indexmap 2.10.0",
  "log",
  "memchr",
  "multer",
@@ -3810,7 +4208,7 @@ checksum = "575d32d7ec1a9770108c879fc7c47815a80073f96ca07ff9525a94fcede1dd46"
 dependencies = [
  "devise",
  "glob",
- "indexmap",
+ "indexmap 2.10.0",
  "proc-macro2",
  "quote",
  "rocket_http",
@@ -3830,7 +4228,7 @@ dependencies = [
  "futures",
  "http 0.2.12",
  "hyper 0.14.32",
- "indexmap",
+ "indexmap 2.10.0",
  "log",
  "memchr",
  "pear",
@@ -3981,7 +4379,7 @@ dependencies = [
  "openssl-probe",
  "rustls-pki-types",
  "schannel",
- "security-framework",
+ "security-framework 3.2.0",
 ]
 
 [[package]]
@@ -4072,6 +4470,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"
@@ -4105,6 +4527,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"
@@ -4133,6 +4582,9 @@ name = "semver"
 version = "1.0.26"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0"
+dependencies = [
+ "serde",
+]
 
 [[package]]
 name = "serde"
@@ -4143,6 +4595,16 @@ dependencies = [
  "serde_derive",
 ]
 
+[[package]]
+name = "serde-value"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c"
+dependencies = [
+ "ordered-float",
+ "serde",
+]
+
 [[package]]
 name = "serde_cbor"
 version = "0.11.2"
@@ -4176,6 +4638,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"
@@ -4206,6 +4687,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"
@@ -4290,6 +4803,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"
@@ -4649,6 +5177,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"
@@ -4724,7 +5262,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",
@@ -4757,7 +5295,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",
@@ -4905,6 +5443,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"
@@ -4961,6 +5505,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"
@@ -5049,7 +5599,7 @@ dependencies = [
  "chrono-tz",
  "cookie",
  "cookie_store",
- "dashmap",
+ "dashmap 6.1.0",
  "data-encoding",
  "data-url",
  "derive_more",
@@ -5074,10 +5624,12 @@ dependencies = [
  "log",
  "macros",
  "mimalloc",
+ "mini-moka",
  "num-derive",
  "num-traits",
  "once_cell",
  "opendal",
+ "openidconnect",
  "openssl",
  "pastey",
  "percent-encoding",

+ 8 - 0
Cargo.toml

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

+ 303 - 0
SSO.md

@@ -0,0 +1,303 @@
+# SSO using OpenId Connect
+
+To use an external source of authentication your SSO will need to support OpenID Connect :
+
+- An OpenID Connect Discovery endpoint should be available
+- Client authentication will be done using Id and Secret.
+
+A master password will still be required and not controlled by the SSO (depending on your point of view this might be a feature ;).
+This introduces another way to control who can use the vault without having to use invitation or using an LDAP.
+
+## Configuration
+
+The following configurations are available
+
+- `SSO_ENABLED` : Activate the SSO
+- `SSO_ONLY` : disable email+Master password authentication
+- `SSO_SIGNUPS_MATCH_EMAIL`: On SSO Signup if a user with a matching email already exists make the association (default `true`)
+- `SSO_ALLOW_UNKNOWN_EMAIL_VERIFICATION`: Allow unknown email verification status (default `false`). Allowing this with `SSO_SIGNUPS_MATCH_EMAIL` open potential account takeover.
+- `SSO_AUTHORITY` : the OpenID Connect Discovery endpoint of your SSO
+  - Should not include the `/.well-known/openid-configuration` part and no trailing `/`
+  - $SSO_AUTHORITY/.well-known/openid-configuration should return the a json document: https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationResponse
+- `SSO_SCOPES` : Optional, allow to override scopes if needed (default `"email profile"`)
+- `SSO_AUTHORIZE_EXTRA_PARAMS` : Optional, allow to add extra parameter to the authorize redirection (default `""`)
+- `SSO_PKCE`: Activate PKCE for the Auth Code flow (default `true`).
+- `SSO_AUDIENCE_TRUSTED`: Optional, Regex to trust additional audience for the IdToken (`client_id` is always trusted). Use single quote when writing the regex: `'^$'`.
+- `SSO_CLIENT_ID` : Client Id
+- `SSO_CLIENT_SECRET` : Client Secret
+- `SSO_MASTER_PASSWORD_POLICY`: Optional Master password policy (`enforceOnLogin` is not supported).
+- `SSO_AUTH_ONLY_NOT_SESSION`: Enable to use SSO only for authentication not session lifecycle
+- `SSO_CLIENT_CACHE_EXPIRATION`: Cache calls to the discovery endpoint, duration in seconds, `0` to disable (default `0`);
+- `SSO_DEBUG_TOKENS`: Log all tokens for easier debugging (default `false`, `LOG_LEVEL=debug` or `LOG_LEVEL=info,vaultwarden::sso=debug` need to be set)
+
+The callback url is : `https://your.domain/identity/connect/oidc-signin`
+
+## Account and Email handling
+
+When logging in with SSO an identifier (`{iss}/{sub}` claims from the IdToken) is saved in a separate table (`sso_users`).
+This is used to link to the SSO provider identifier without changing the default user `uuid`. This is needed because:
+
+- Storing the SSO identifier is important to prevent account takeover due to email change.
+- We can't use the identifier as the User uuid since it's way longer (Max 255 chars for the `sub` part, cf [spec](https://openid.net/specs/openid-connect-core-1_0.html#CodeIDToken)).
+- We want to be able to associate existing account based on `email` but only when the user logs in for the first time (controlled by `SSO_SIGNUPS_MATCH_EMAIL`).
+- We need to be able to associate with existing stub account, such as the one created when inviting a user to an org (association is possible only if the user does not have a private key).
+
+Additionally:
+
+- Signup will be blocked if the Provider reports the email as `unverified`.
+- Changing the email needs to be done by the user since it requires updating the `key`.
+  On login if the email returned by the provider is not the one saved an email will be sent to the user to ask him to update it.
+- If set, `SIGNUPS_DOMAINS_WHITELIST` is applied on SSO signup and when attempting to change the email.
+
+This means that if you ever need to change the provider url or the provider itself; you'll have to first delete the association
+then ensure that `SSO_SIGNUPS_MATCH_EMAIL` is activated to allow a new association.
+
+To delete the association (this has no impact on the `Vaultwarden` user):
+
+```sql
+TRUNCATE TABLE sso_users;
+```
+
+### On `SSO_ALLOW_UNKNOWN_EMAIL_VERIFICATION`
+
+If your provider does not send the verification status of emails (`email_verified` [claim](https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims)) you will need to activate this setting.
+
+If set with `SSO_SIGNUPS_MATCH_EMAIL=true` (the default), then a user can associate with an existing, non-SSO account, even if they do not control the email address.
+This allow a user to gain access to sensitive information but the master password is still required to read the passwords.
+
+As such when using `SSO_ALLOW_UNKNOWN_EMAIL_VERIFICATION` it is recommended to disable `SSO_SIGNUPS_MATCH_EMAIL`.
+If you need to associate non sso users try to keep both settings activated for the shortest time possible.
+
+## Client Cache
+
+By default the client cache is disabled since it can cause issues with the signing keys.
+\
+This means that the discovery endpoint will be called again each time we need to interact with the provider (generating authorize_url, exchange the authorize code, refresh tokens).
+This is suboptimal so the `SSO_CLIENT_CACHE_EXPIRATION` allows you to configure an expiration that should work for your provider.
+
+As a protection against a misconfigured expiration if the validation of the `IdToken` fails then the client cache is invalidated (but you'll periodically have an unlucky user ^^).
+
+### Google example (Rolling keys)
+
+If we take Google as an example checking the discovery [endpoint](https://accounts.google.com/.well-known/openid-configuration) response headers we can see that the `max-age` of the cache control is set to `3600` seconds. And the [jwk_uri](https://www.googleapis.com/oauth2/v3/certs) response headers usually contain a `max-age` with an even bigger value.
+/
+Combined with user [feedback](https://github.com/ramosbugs/openidconnect-rs/issues/152) we can conclude that Google will roll the signing keys each week.
+
+Setting the cache expiration too high has diminishing return but using something like `600` (10 min) should provide plenty benefits.
+
+### Rolling keys manually
+
+If you want to roll the used key, first add a new one but do not immediately start signing with it.
+Wait for the delay you configured in `SSO_CLIENT_CACHE_EXPIRATION` then you can start signing with it.
+
+As mentioned in the Google example setting too high of a value has diminishing return even if you do not plan to roll the keys.
+
+## Keycloak
+
+Default access token lifetime might be only `5min`, set a longer value otherwise it will collide with `Bitwarden` front-end expiration detection which is also set at `5min`.
+\
+At the realm level
+
+- `Realm settings / Tokens / Access Token Lifespan` to at least `10min` (`accessTokenLifespan` setting when using `kcadm.sh`).
+- `Realm settings / Sessions / SSO Session Idle/Max` for the Refresh token lifetime
+
+Or for a specific client in `Clients / Client details / Advanced / Advanced settings` you can find `Access Token Lifespan` and `Client Session Idle/Max`.
+
+Server configuration, nothing specific just set:
+
+- `SSO_AUTHORITY=https://${domain}/realms/${realm_name}`
+- `SSO_CLIENT_ID`
+- `SSO_CLIENT_SECRET`
+
+### Testing
+
+If you want to run a testing instance of Keycloak the Playwright [docker-compose](playwright/docker-compose.yml) can be used.
+\
+More details on how to use it in [README.md](playwright/README.md#openid-connect-test-setup).
+
+## Auth0
+
+Not working due to the following issue https://github.com/ramosbugs/openidconnect-rs/issues/23 (they appear not to follow the spec).
+A feature flag is available (`oidc-accept-rfc3339-timestamps`) to bypass the issue but you will need to compile the server with it.
+There is no plan at the moment to either always activate the feature nor make a specific distribution for Auth0.
+
+## Authelia
+
+To obtain a `refresh_token` to be able to extend session you'll need to add the `offline_access` scope.
+
+Config will look like:
+
+- `SSO_SCOPES="email profile offline_access"`
+
+
+## Authentik
+
+Default access token lifetime might be only `5min`, set a longer value otherwise it will collide with `Bitwarden` front-end expiration detection which is also set at `5min`.
+\
+To change the tokens expiration go to `Applications / Providers / Edit / Advanced protocol settings`.
+
+Starting with `2024.2` version you will need to add the `offline_access` scope and ensure it's selected in `Applications / Providers / Edit / Advanced protocol settings / Scopes` ([Doc](https://docs.goauthentik.io/docs/providers/oauth2/#authorization_code)).
+
+Server configuration should look like:
+
+- `SSO_AUTHORITY=https://${domain}/application/o/${application_name}/` : trailing `/` is important
+- `SSO_SCOPES="email profile offline_access"`
+- `SSO_CLIENT_ID`
+- `SSO_CLIENT_SECRET`
+
+## Casdoor
+
+Since version [v1.639.0](https://github.com/casdoor/casdoor/releases/tag/v1.639.0) should work (Tested with version [v1.686.0](https://github.com/casdoor/casdoor/releases/tag/v1.686.0)).
+When creating the application you will need to select the `Token format -> JWT-Standard`.
+
+Then configure your server with:
+
+- `SSO_AUTHORITY=https://${provider_host}`
+- `SSO_CLIENT_ID`
+- `SSO_CLIENT_SECRET`
+
+## GitLab
+
+Create an application in your Gitlab Settings with
+
+- `redirectURI`: https://your.domain/identity/connect/oidc-signin
+- `Confidential`: `true`
+- `scopes`: `openid`, `profile`, `email`
+
+Then configure your server with
+
+- `SSO_AUTHORITY=https://gitlab.com`
+- `SSO_CLIENT_ID`
+- `SSO_CLIENT_SECRET`
+
+## Google Auth
+
+Google [Documentation](https://developers.google.com/identity/openid-connect/openid-connect).
+\
+By default without extra [configuration](https://developers.google.com/identity/protocols/oauth2/web-server#creatingclient) you won´t have a `refresh_token` and session will be limited to 1h.
+
+Configure your server with :
+
+- `SSO_AUTHORITY=https://accounts.google.com`
+- `SSO_AUTHORIZE_EXTRA_PARAMS="access_type=offline&prompt=consent"`
+- `SSO_CLIENT_ID`
+- `SSO_CLIENT_SECRET`
+
+## Kanidm
+
+Nothing specific should work with just `SSO_AUTHORITY`, `SSO_CLIENT_ID` and `SSO_CLIENT_SECRET`.
+
+## Microsoft Entra ID
+
+1. Create an "App registration" in [Entra ID](https://entra.microsoft.com/) following [Identity | Applications | App registrations](https://entra.microsoft.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade/quickStartType//sourceType/Microsoft_AAD_IAM).
+2. From the "Overview" of your "App registration", you'll need the "Directory (tenant) ID" for the `SSO_AUTHORITY` variable and the "Application (client) ID" as the `SSO_CLIENT_ID` value.
+3. In "Certificates & Secrets" create an "App secret" , you'll need the "Secret Value" for the `SSO_CLIENT_SECRET` variable.
+4. In "Authentication" add <https://warden.example.org/identity/connect/oidc-signin> as "Web Redirect URI".
+5. In "API Permissions" make sure you have `profile`, `email` and `offline_access` listed under "API / Permission name" (`offline_access` is required, otherwise no refresh_token is returned, see <https://github.com/MicrosoftDocs/azure-docs/issues/17134>).
+
+Only the v2 endpoint is compliant with the OpenID spec, see <https://github.com/MicrosoftDocs/azure-docs/issues/38427> and <https://github.com/ramosbugs/openidconnect-rs/issues/122>.
+
+Your configuration should look like this:
+
+* `SSO_AUTHORITY=https://login.microsoftonline.com/${Directory (tenant) ID}/v2.0`
+* `SSO_SCOPES="email profile offline_access"`
+* `SSO_CLIENT_ID=${Application (client) ID}`
+* `SSO_CLIENT_SECRET=${Secret Value}`
+
+## Rauthy
+
+To use a provider controlled session you will need to run Rauthy with `DISABLE_REFRESH_TOKEN_NBF=true` otherwise the server will fail when trying to read a not yet valid `refresh_token` (`Bitwarden` clients will trigger a refresh even if the `access_token` is still valid. Details on rauthy [side](https://github.com/sebadob/rauthy/issues/651)). Alternative is to use the default session handling with `SSO_AUTH_ONLY_NOT_SESSION=true`.
+
+No specific config needed when creating the Client.
+
+Your configuration should look like this:
+
+* `SSO_AUTHORITY=http://${provider_host}/auth/v1`
+* `SSO_CLIENT_ID=${Client ID}`
+* `SSO_CLIENT_SECRET=${Client Secret}`
+* `SSO_AUTH_ONLY_NOT_SESSION=true` Only needed if not running `Rauthy` with `DISABLE_REFRESH_TOKEN_NBF=true`
+
+## Slack
+
+You will need to create an app in https://api.slack.com/apps/.
+
+It appears that the `access_token` returned is not in JWT format and an expiration date is not sent with it. As such you will need to use the default session lifecycle.
+
+Your configuration should look like this:
+
+* `SSO_AUTHORITY=https://slack.com`
+* `SSO_CLIENT_ID=${Application Client ID}`
+* `SSO_CLIENT_SECRET=${Application Client Secret}`
+* `SSO_AUTH_ONLY_NOT_SESSION=true`
+
+## Zitadel
+
+To obtain a `refresh_token` to be able to extend session you'll need to add the `offline_access` scope.
+
+Additionally Zitadel include the `Project id` and the `Client Id` in the audience of the Id Token.
+For the validation to work you will need to add the `Resource Id` as a trusted audience (`Client Id` is trusted by default).
+You can control the trusted audience with the config `SSO_AUDIENCE_TRUSTED`
+
+Since [zitadel#721](https://github.com/zitadel/oidc/pull/721) PKCE should work with client secret.
+But older versions might have to disable it (`SSO_PKCE=false`).
+
+Config will look like:
+
+- `SSO_AUTHORITY=https://${provider_host}`
+- `SSO_SCOPES="email profile offline_access"`
+- `SSO_CLIENT_ID`
+- `SSO_CLIENT_SECRET`
+- `SSO_AUDIENCE_TRUSTED='^${Project Id}$'`
+
+## Session lifetime
+
+Session lifetime is dependant on refresh token and access token returned after calling your SSO token endpoint (grant type `authorization_code`).
+If no refresh token is returned then the session will be limited to the access token lifetime.
+
+Tokens are not persisted in the server but wrapped in JWT tokens and returned to the application (The `refresh_token` and `access_token` values returned by VW `identity/connect/token` endpoint).
+Note that the server will always return a `refresh_token` for compatibility reasons with the web front and it presence does not indicate that a refresh token was returned by your SSO (But you can decode its value with <https://jwt.io> and then check if the `token` field contain anything).
+
+With a refresh token present, activity in the application will trigger a refresh of the access token when it's close to expiration ([5min](https://github.com/bitwarden/clients/blob/0bcb45ed5caa990abaff735553a5046e85250f24/libs/common/src/auth/services/token.service.ts#L126) in web client).
+
+Additionally for certain action a token check is performed, if we have a refresh token we will perform a refresh otherwise we'll call the user information endpoint to check the access token validity.
+
+### Disabling SSO session handling
+
+If you are unable to obtain a `refresh_token` or for any other reason you can disable SSO session handling and revert to the default handling.
+You'll need to enable `SSO_AUTH_ONLY_NOT_SESSION=true` then access token will be valid for 2h and refresh token will allow for an idle time of 7 days (which can be indefinitely extended).
+
+### Debug information
+
+Running with `LOG_LEVEL=debug` you'll be able to see information on token expiration.
+
+## Desktop Client
+
+There is some issue to handle redirection from your browser (used for sso login) to the application.
+
+### Chrome
+
+Some user report having ([issues](https://github.com/bitwarden/clients/issues/12929)).
+
+## Firefox
+
+On Windows you'll be presented with a prompt the first time you log to confirm which application should be launched (But there is a bug at the moment you might end-up with an empty vault after login atm).
+
+
+On Linux it's a bit more tricky.
+First you'll need to add some config in `about:config` :
+
+```conf
+network.protocol-handler.expose.bitwarden=false
+network.protocol-handler.external.bitwarden=true
+```
+
+If you have any doubt you can check `mailto` to see how it's configured.
+
+The redirection will still not work since it appears that the association to an application can only be done on a link/click. You can trigger it with a dummy page such as:
+
+```html
+data:text/html,<a href="bitwarden:///dummy">Click me to register Bitwarden</a>
+```
+
+From now on the redirection should now work.
+If you need to change the application launched you can now find it in `Settings` by using the search function and entering `application`.

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

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

+ 4 - 0
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 - 0
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 - 0
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 - 0
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 - 0
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 - 0
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 - 0
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 - 0
migrations/mysql/2024-03-06-170000_add_sso_users/down.sql

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

+ 7 - 0
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 - 0
migrations/mysql/2024-03-13-170000_sso_users_cascade/down.sql


+ 2 - 0
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 - 0
migrations/postgresql/2023-09-10-133000_add_sso/down.sql

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

+ 4 - 0
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 - 0
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 - 0
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 - 0
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 - 0
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 - 0
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 - 0
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 - 0
migrations/postgresql/2024-03-06-170000_add_sso_users/down.sql

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

+ 7 - 0
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 - 0
migrations/postgresql/2024-03-13-170000_sso_users_cascade/down.sql


+ 3 - 0
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 - 0
migrations/sqlite/2023-09-10-133000_add_sso/down.sql

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

+ 4 - 0
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 - 0
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 - 0
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 - 0
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 - 0
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 - 0
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 - 0
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 - 0
migrations/sqlite/2024-03-06-170000_add_sso_users/down.sql

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

+ 7 - 0
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 - 0
migrations/sqlite/2024-03-13_170000_sso_userscascade/down.sql


+ 9 - 0
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 - 0
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 - 0
playwright/.gitignore

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

+ 166 - 0
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 `[email protected]/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 - 0
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 - 0
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 - 0
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 - 0
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 - 0
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 - 0
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 - 0
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 - 0
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 - 0
playwright/package-lock.json

@@ -0,0 +1,2547 @@
+{
+    "name": "scenarios",
+    "version": "1.0.0",
+    "lockfileVersion": 3,
+    "requires": true,
+    "packages": {
+        "": {
+            "name": "scenarios",
+            "version": "1.0.0",
+            "license": "ISC",
+            "dependencies": {
+                "mysql2": "^3.14.1",
+                "otpauth": "^9.4.0",
+                "pg": "^8.16.0"
+            },
+            "devDependencies": {
+                "@playwright/test": "^1.53.0",
+                "dotenv": "^16.5.0",
+                "dotenv-expand": "^12.0.2",
+                "maildev": "npm:@timshel_npm/maildev@^3.1.2"
+            }
+        },
+        "node_modules/@asamuzakjp/css-color": {
+            "version": "3.2.0",
+            "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
+            "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
+            "dev": true,
+            "dependencies": {
+                "@csstools/css-calc": "^2.1.3",
+                "@csstools/css-color-parser": "^3.0.9",
+                "@csstools/css-parser-algorithms": "^3.0.4",
+                "@csstools/css-tokenizer": "^3.0.3",
+                "lru-cache": "^10.4.3"
+            }
+        },
+        "node_modules/@csstools/color-helpers": {
+            "version": "5.0.2",
+            "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.2.tgz",
+            "integrity": "sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==",
+            "dev": true,
+            "funding": [
+                {
+                    "type": "github",
+                    "url": "https://github.com/sponsors/csstools"
+                },
+                {
+                    "type": "opencollective",
+                    "url": "https://opencollective.com/csstools"
+                }
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/@csstools/css-calc": {
+            "version": "2.1.4",
+            "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
+            "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
+            "dev": true,
+            "funding": [
+                {
+                    "type": "github",
+                    "url": "https://github.com/sponsors/csstools"
+                },
+                {
+                    "type": "opencollective",
+                    "url": "https://opencollective.com/csstools"
+                }
+            ],
+            "engines": {
+                "node": ">=18"
+            },
+            "peerDependencies": {
+                "@csstools/css-parser-algorithms": "^3.0.5",
+                "@csstools/css-tokenizer": "^3.0.4"
+            }
+        },
+        "node_modules/@csstools/css-color-parser": {
+            "version": "3.0.10",
+            "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.0.10.tgz",
+            "integrity": "sha512-TiJ5Ajr6WRd1r8HSiwJvZBiJOqtH86aHpUjq5aEKWHiII2Qfjqd/HCWKPOW8EP4vcspXbHnXrwIDlu5savQipg==",
+            "dev": true,
+            "funding": [
+                {
+                    "type": "github",
+                    "url": "https://github.com/sponsors/csstools"
+                },
+                {
+                    "type": "opencollective",
+                    "url": "https://opencollective.com/csstools"
+                }
+            ],
+            "dependencies": {
+                "@csstools/color-helpers": "^5.0.2",
+                "@csstools/css-calc": "^2.1.4"
+            },
+            "engines": {
+                "node": ">=18"
+            },
+            "peerDependencies": {
+                "@csstools/css-parser-algorithms": "^3.0.5",
+                "@csstools/css-tokenizer": "^3.0.4"
+            }
+        },
+        "node_modules/@csstools/css-parser-algorithms": {
+            "version": "3.0.5",
+            "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
+            "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
+            "dev": true,
+            "funding": [
+                {
+                    "type": "github",
+                    "url": "https://github.com/sponsors/csstools"
+                },
+                {
+                    "type": "opencollective",
+                    "url": "https://opencollective.com/csstools"
+                }
+            ],
+            "engines": {
+                "node": ">=18"
+            },
+            "peerDependencies": {
+                "@csstools/css-tokenizer": "^3.0.4"
+            }
+        },
+        "node_modules/@csstools/css-tokenizer": {
+            "version": "3.0.4",
+            "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
+            "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
+            "dev": true,
+            "funding": [
+                {
+                    "type": "github",
+                    "url": "https://github.com/sponsors/csstools"
+                },
+                {
+                    "type": "opencollective",
+                    "url": "https://opencollective.com/csstools"
+                }
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/@noble/hashes": {
+            "version": "1.7.1",
+            "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.1.tgz",
+            "integrity": "sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ==",
+            "engines": {
+                "node": "^14.21.3 || >=16"
+            },
+            "funding": {
+                "url": "https://paulmillr.com/funding/"
+            }
+        },
+        "node_modules/@playwright/test": {
+            "version": "1.53.0",
+            "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.0.tgz",
+            "integrity": "sha512-15hjKreZDcp7t6TL/7jkAo6Df5STZN09jGiv5dbP9A6vMVncXRqE7/B2SncsyOwrkZRBH2i6/TPOL8BVmm3c7w==",
+            "dev": true,
+            "dependencies": {
+                "playwright": "1.53.0"
+            },
+            "bin": {
+                "playwright": "cli.js"
+            },
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/@selderee/plugin-htmlparser2": {
+            "version": "0.11.0",
+            "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz",
+            "integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==",
+            "dev": true,
+            "dependencies": {
+                "domhandler": "^5.0.3",
+                "selderee": "^0.11.0"
+            },
+            "funding": {
+                "url": "https://ko-fi.com/killymxi"
+            }
+        },
+        "node_modules/@socket.io/component-emitter": {
+            "version": "3.1.2",
+            "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
+            "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
+            "dev": true
+        },
+        "node_modules/@types/cors": {
+            "version": "2.8.19",
+            "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
+            "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
+            "dev": true,
+            "dependencies": {
+                "@types/node": "*"
+            }
+        },
+        "node_modules/@types/mailparser": {
+            "version": "3.4.6",
+            "resolved": "https://registry.npmjs.org/@types/mailparser/-/mailparser-3.4.6.tgz",
+            "integrity": "sha512-wVV3cnIKzxTffaPH8iRnddX1zahbYB1ZEoAxyhoBo3TBCBuK6nZ8M8JYO/RhsCuuBVOw/DEN/t/ENbruwlxn6Q==",
+            "dev": true,
+            "dependencies": {
+                "@types/node": "*",
+                "iconv-lite": "^0.6.3"
+            }
+        },
+        "node_modules/@types/node": {
+            "version": "24.0.1",
+            "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.1.tgz",
+            "integrity": "sha512-MX4Zioh39chHlDJbKmEgydJDS3tspMP/lnQC67G3SWsTnb9NeYVWOjkxpOSy4oMfPs4StcWHwBrvUb4ybfnuaw==",
+            "dev": true,
+            "dependencies": {
+                "undici-types": "~7.8.0"
+            }
+        },
+        "node_modules/@types/trusted-types": {
+            "version": "2.0.7",
+            "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+            "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+            "dev": true,
+            "optional": true
+        },
+        "node_modules/accepts": {
+            "version": "1.3.8",
+            "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+            "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+            "dev": true,
+            "dependencies": {
+                "mime-types": "~2.1.34",
+                "negotiator": "0.6.3"
+            },
+            "engines": {
+                "node": ">= 0.6"
+            }
+        },
+        "node_modules/accepts/node_modules/negotiator": {
+            "version": "0.6.3",
+            "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+            "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+            "dev": true,
+            "engines": {
+                "node": ">= 0.6"
+            }
+        },
+        "node_modules/addressparser": {
+            "version": "1.0.1",
+            "resolved": "https://registry.npmjs.org/addressparser/-/addressparser-1.0.1.tgz",
+            "integrity": "sha512-aQX7AISOMM7HFE0iZ3+YnD07oIeJqWGVnJ+ZIKaBZAk03ftmVYVqsGas/rbXKR21n4D/hKCSHypvcyOkds/xzg==",
+            "dev": true
+        },
+        "node_modules/agent-base": {
+            "version": "7.1.3",
+            "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz",
+            "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==",
+            "dev": true,
+            "engines": {
+                "node": ">= 14"
+            }
+        },
+        "node_modules/array-flatten": {
+            "version": "1.1.1",
+            "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+            "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+            "dev": true
+        },
+        "node_modules/async": {
+            "version": "3.2.6",
+            "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
+            "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
+            "dev": true
+        },
+        "node_modules/asynckit": {
+            "version": "0.4.0",
+            "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+            "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+            "dev": true
+        },
+        "node_modules/aws-ssl-profiles": {
+            "version": "1.1.2",
+            "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
+            "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
+            "engines": {
+                "node": ">= 6.0.0"
+            }
+        },
+        "node_modules/base32.js": {
+            "version": "0.1.0",
+            "resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.1.0.tgz",
+            "integrity": "sha512-n3TkB02ixgBOhTvANakDb4xaMXnYUVkNoRFJjQflcqMQhyEKxEHdj3E6N8t8sUQ0mjH/3/JxzlXuz3ul/J90pQ==",
+            "dev": true,
+            "engines": {
+                "node": ">=0.12.0"
+            }
+        },
+        "node_modules/base64id": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
+            "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==",
+            "dev": true,
+            "engines": {
+                "node": "^4.5.0 || >= 5.9"
+            }
+        },
+        "node_modules/body-parser": {
+            "version": "1.20.3",
+            "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
+            "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==",
+            "dev": true,
+            "dependencies": {
+                "bytes": "3.1.2",
+                "content-type": "~1.0.5",
+                "debug": "2.6.9",
+                "depd": "2.0.0",
+                "destroy": "1.2.0",
+                "http-errors": "2.0.0",
+                "iconv-lite": "0.4.24",
+                "on-finished": "2.4.1",
+                "qs": "6.13.0",
+                "raw-body": "2.5.2",
+                "type-is": "~1.6.18",
+                "unpipe": "1.0.0"
+            },
+            "engines": {
+                "node": ">= 0.8",
+                "npm": "1.2.8000 || >= 1.4.16"
+            }
+        },
+        "node_modules/body-parser/node_modules/iconv-lite": {
+            "version": "0.4.24",
+            "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+            "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+            "dev": true,
+            "dependencies": {
+                "safer-buffer": ">= 2.1.2 < 3"
+            },
+            "engines": {
+                "node": ">=0.10.0"
+            }
+        },
+        "node_modules/bytes": {
+            "version": "3.1.2",
+            "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+            "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+            "dev": true,
+            "engines": {
+                "node": ">= 0.8"
+            }
+        },
+        "node_modules/call-bind-apply-helpers": {
+            "version": "1.0.2",
+            "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+            "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+            "dev": true,
+            "dependencies": {
+                "es-errors": "^1.3.0",
+                "function-bind": "^1.1.2"
+            },
+            "engines": {
+                "node": ">= 0.4"
+            }
+        },
+        "node_modules/call-bound": {
+            "version": "1.0.4",
+            "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+            "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+            "dev": true,
+            "dependencies": {
+                "call-bind-apply-helpers": "^1.0.2",
+                "get-intrinsic": "^1.3.0"
+            },
+            "engines": {
+                "node": ">= 0.4"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "node_modules/combined-stream": {
+            "version": "1.0.8",
+            "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+            "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+            "dev": true,
+            "dependencies": {
+                "delayed-stream": "~1.0.0"
+            },
+            "engines": {
+                "node": ">= 0.8"
+            }
+        },
+        "node_modules/commander": {
+            "version": "12.1.0",
+            "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
+            "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
+            "dev": true,
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/compressible": {
+            "version": "2.0.18",
+            "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
+            "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
+            "dev": true,
+            "dependencies": {
+                "mime-db": ">= 1.43.0 < 2"
+            },
+            "engines": {
+                "node": ">= 0.6"
+            }
+        },
+        "node_modules/compression": {
+            "version": "1.8.0",
+            "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.0.tgz",
+            "integrity": "sha512-k6WLKfunuqCYD3t6AsuPGvQWaKwuLLh2/xHNcX4qE+vIfDNXpSqnrhwA7O53R7WVQUnt8dVAIW+YHr7xTgOgGA==",
+            "dev": true,
+            "dependencies": {
+                "bytes": "3.1.2",
+                "compressible": "~2.0.18",
+                "debug": "2.6.9",
+                "negotiator": "~0.6.4",
+                "on-headers": "~1.0.2",
+                "safe-buffer": "5.2.1",
+                "vary": "~1.1.2"
+            },
+            "engines": {
+                "node": ">= 0.8.0"
+            }
+        },
+        "node_modules/content-disposition": {
+            "version": "0.5.4",
+            "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+            "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+            "dev": true,
+            "dependencies": {
+                "safe-buffer": "5.2.1"
+            },
+            "engines": {
+                "node": ">= 0.6"
+            }
+        },
+        "node_modules/content-type": {
+            "version": "1.0.5",
+            "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+            "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+            "dev": true,
+            "engines": {
+                "node": ">= 0.6"
+            }
+        },
+        "node_modules/cookie": {
+            "version": "0.7.1",
+            "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
+            "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
+            "dev": true,
+            "engines": {
+                "node": ">= 0.6"
+            }
+        },
+        "node_modules/cookie-signature": {
+            "version": "1.0.6",
+            "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
+            "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
+            "dev": true
+        },
+        "node_modules/cors": {
+            "version": "2.8.5",
+            "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
+            "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
+            "dev": true,
+            "dependencies": {
+                "object-assign": "^4",
+                "vary": "^1"
+            },
+            "engines": {
+                "node": ">= 0.10"
+            }
+        },
+        "node_modules/cssstyle": {
+            "version": "4.4.0",
+            "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.4.0.tgz",
+            "integrity": "sha512-W0Y2HOXlPkb2yaKrCVRjinYKciu/qSLEmK0K9mcfDei3zwlnHFEHAs/Du3cIRwPqY+J4JsiBzUjoHyc8RsJ03A==",
+            "dev": true,
+            "dependencies": {
+                "@asamuzakjp/css-color": "^3.2.0",
+                "rrweb-cssom": "^0.8.0"
+            },
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/cssstyle/node_modules/rrweb-cssom": {
+            "version": "0.8.0",
+            "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
+            "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
+            "dev": true
+        },
+        "node_modules/data-urls": {
+            "version": "5.0.0",
+            "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
+            "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
+            "dev": true,
+            "dependencies": {
+                "whatwg-mimetype": "^4.0.0",
+                "whatwg-url": "^14.0.0"
+            },
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/debug": {
+            "version": "2.6.9",
+            "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+            "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+            "dev": true,
+            "dependencies": {
+                "ms": "2.0.0"
+            }
+        },
+        "node_modules/decimal.js": {
+            "version": "10.5.0",
+            "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz",
+            "integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==",
+            "dev": true
+        },
+        "node_modules/deepmerge": {
+            "version": "4.3.1",
+            "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
+            "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
+            "dev": true,
+            "engines": {
+                "node": ">=0.10.0"
+            }
+        },
+        "node_modules/delayed-stream": {
+            "version": "1.0.0",
+            "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+            "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+            "dev": true,
+            "engines": {
+                "node": ">=0.4.0"
+            }
+        },
+        "node_modules/denque": {
+            "version": "2.1.0",
+            "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
+            "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
+            "engines": {
+                "node": ">=0.10"
+            }
+        },
+        "node_modules/depd": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+            "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+            "dev": true,
+            "engines": {
+                "node": ">= 0.8"
+            }
+        },
+        "node_modules/destroy": {
+            "version": "1.2.0",
+            "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+            "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+            "dev": true,
+            "engines": {
+                "node": ">= 0.8",
+                "npm": "1.2.8000 || >= 1.4.16"
+            }
+        },
+        "node_modules/dom-serializer": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+            "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+            "dev": true,
+            "dependencies": {
+                "domelementtype": "^2.3.0",
+                "domhandler": "^5.0.2",
+                "entities": "^4.2.0"
+            },
+            "funding": {
+                "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+            }
+        },
+        "node_modules/domelementtype": {
+            "version": "2.3.0",
+            "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+            "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+            "dev": true,
+            "funding": [
+                {
+                    "type": "github",
+                    "url": "https://github.com/sponsors/fb55"
+                }
+            ]
+        },
+        "node_modules/domhandler": {
+            "version": "5.0.3",
+            "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+            "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+            "dev": true,
+            "dependencies": {
+                "domelementtype": "^2.3.0"
+            },
+            "engines": {
+                "node": ">= 4"
+            },
+            "funding": {
+                "url": "https://github.com/fb55/domhandler?sponsor=1"
+            }
+        },
+        "node_modules/dompurify": {
+            "version": "3.2.6",
+            "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz",
+            "integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==",
+            "dev": true,
+            "optionalDependencies": {
+                "@types/trusted-types": "^2.0.7"
+            }
+        },
+        "node_modules/domutils": {
+            "version": "3.2.2",
+            "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
+            "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
+            "dev": true,
+            "dependencies": {
+                "dom-serializer": "^2.0.0",
+                "domelementtype": "^2.3.0",
+                "domhandler": "^5.0.3"
+            },
+            "funding": {
+                "url": "https://github.com/fb55/domutils?sponsor=1"
+            }
+        },
+        "node_modules/dotenv": {
+            "version": "16.5.0",
+            "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz",
+            "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==",
+            "dev": true,
+            "engines": {
+                "node": ">=12"
+            },
+            "funding": {
+                "url": "https://dotenvx.com"
+            }
+        },
+        "node_modules/dotenv-expand": {
+            "version": "12.0.2",
+            "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.2.tgz",
+            "integrity": "sha512-lXpXz2ZE1cea1gL4sz2Ipj8y4PiVjytYr3Ij0SWoms1PGxIv7m2CRKuRuCRtHdVuvM/hNJPMxt5PbhboNC4dPQ==",
+            "dev": true,
+            "dependencies": {
+                "dotenv": "^16.4.5"
+            },
+            "engines": {
+                "node": ">=12"
+            },
+            "funding": {
+                "url": "https://dotenvx.com"
+            }
+        },
+        "node_modules/dunder-proto": {
+            "version": "1.0.1",
+            "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+            "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+            "dev": true,
+            "dependencies": {
+                "call-bind-apply-helpers": "^1.0.1",
+                "es-errors": "^1.3.0",
+                "gopd": "^1.2.0"
+            },
+            "engines": {
+                "node": ">= 0.4"
+            }
+        },
+        "node_modules/ee-first": {
+            "version": "1.1.1",
+            "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+            "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+            "dev": true
+        },
+        "node_modules/encodeurl": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+            "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+            "dev": true,
+            "engines": {
+                "node": ">= 0.8"
+            }
+        },
+        "node_modules/encoding-japanese": {
+            "version": "2.2.0",
+            "resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz",
+            "integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==",
+            "dev": true,
+            "engines": {
+                "node": ">=8.10.0"
+            }
+        },
+        "node_modules/engine.io": {
+            "version": "6.6.4",
+            "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.4.tgz",
+            "integrity": "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g==",
+            "dev": true,
+            "dependencies": {
+                "@types/cors": "^2.8.12",
+                "@types/node": ">=10.0.0",
+                "accepts": "~1.3.4",
+                "base64id": "2.0.0",
+                "cookie": "~0.7.2",
+                "cors": "~2.8.5",
+                "debug": "~4.3.1",
+                "engine.io-parser": "~5.2.1",
+                "ws": "~8.17.1"
+            },
+            "engines": {
+                "node": ">=10.2.0"
+            }
+        },
+        "node_modules/engine.io-parser": {
+            "version": "5.2.3",
+            "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
+            "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
+            "dev": true,
+            "engines": {
+                "node": ">=10.0.0"
+            }
+        },
+        "node_modules/engine.io/node_modules/cookie": {
+            "version": "0.7.2",
+            "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+            "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+            "dev": true,
+            "engines": {
+                "node": ">= 0.6"
+            }
+        },
+        "node_modules/engine.io/node_modules/debug": {
+            "version": "4.3.7",
+            "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
+            "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
+            "dev": true,
+            "dependencies": {
+                "ms": "^2.1.3"
+            },
+            "engines": {
+                "node": ">=6.0"
+            },
+            "peerDependenciesMeta": {
+                "supports-color": {
+                    "optional": true
+                }
+            }
+        },
+        "node_modules/engine.io/node_modules/ms": {
+            "version": "2.1.3",
+            "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+            "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+            "dev": true
+        },
+        "node_modules/engine.io/node_modules/ws": {
+            "version": "8.17.1",
+            "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
+            "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
+            "dev": true,
+            "engines": {
+                "node": ">=10.0.0"
+            },
+            "peerDependencies": {
+                "bufferutil": "^4.0.1",
+                "utf-8-validate": ">=5.0.2"
+            },
+            "peerDependenciesMeta": {
+                "bufferutil": {
+                    "optional": true
+                },
+                "utf-8-validate": {
+                    "optional": true
+                }
+            }
+        },
+        "node_modules/entities": {
+            "version": "4.5.0",
+            "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+            "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+            "dev": true,
+            "engines": {
+                "node": ">=0.12"
+            },
+            "funding": {
+                "url": "https://github.com/fb55/entities?sponsor=1"
+            }
+        },
+        "node_modules/es-define-property": {
+            "version": "1.0.1",
+            "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+            "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+            "dev": true,
+            "engines": {
+                "node": ">= 0.4"
+            }
+        },
+        "node_modules/es-errors": {
+            "version": "1.3.0",
+            "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+            "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+            "dev": true,
+            "engines": {
+                "node": ">= 0.4"
+            }
+        },
+        "node_modules/es-object-atoms": {
+            "version": "1.1.1",
+            "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+            "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+            "dev": true,
+            "dependencies": {
+                "es-errors": "^1.3.0"
+            },
+            "engines": {
+                "node": ">= 0.4"
+            }
+        },
+        "node_modules/es-set-tostringtag": {
+            "version": "2.1.0",
+            "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+            "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+            "dev": true,
+            "dependencies": {
+                "es-errors": "^1.3.0",
+                "get-intrinsic": "^1.2.6",
+                "has-tostringtag": "^1.0.2",
+                "hasown": "^2.0.2"
+            },
+            "engines": {
+                "node": ">= 0.4"
+            }
+        },
+        "node_modules/escape-html": {
+            "version": "1.0.3",
+            "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+            "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+            "dev": true
+        },
+        "node_modules/etag": {
+            "version": "1.8.1",
+            "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+            "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+            "dev": true,
+            "engines": {
+                "node": ">= 0.6"
+            }
+        },
+        "node_modules/express": {
+            "version": "4.21.2",
+            "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
+            "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
+            "dev": true,
+            "dependencies": {
+                "accepts": "~1.3.8",
+                "array-flatten": "1.1.1",
+                "body-parser": "1.20.3",
+                "content-disposition": "0.5.4",
+                "content-type": "~1.0.4",
+                "cookie": "0.7.1",
+                "cookie-signature": "1.0.6",
+                "debug": "2.6.9",
+                "depd": "2.0.0",
+                "encodeurl": "~2.0.0",
+                "escape-html": "~1.0.3",
+                "etag": "~1.8.1",
+                "finalhandler": "1.3.1",
+                "fresh": "0.5.2",
+                "http-errors": "2.0.0",
+                "merge-descriptors": "1.0.3",
+                "methods": "~1.1.2",
+                "on-finished": "2.4.1",
+                "parseurl": "~1.3.3",
+                "path-to-regexp": "0.1.12",
+                "proxy-addr": "~2.0.7",
+                "qs": "6.13.0",
+                "range-parser": "~1.2.1",
+                "safe-buffer": "5.2.1",
+                "send": "0.19.0",
+                "serve-static": "1.16.2",
+                "setprototypeof": "1.2.0",
+                "statuses": "2.0.1",
+                "type-is": "~1.6.18",
+                "utils-merge": "1.0.1",
+                "vary": "~1.1.2"
+            },
+            "engines": {
+                "node": ">= 0.10.0"
+            },
+            "funding": {
+                "type": "opencollective",
+                "url": "https://opencollective.com/express"
+            }
+        },
+        "node_modules/finalhandler": {
+            "version": "1.3.1",
+            "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
+            "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
+            "dev": true,
+            "dependencies": {
+                "debug": "2.6.9",
+                "encodeurl": "~2.0.0",
+                "escape-html": "~1.0.3",
+                "on-finished": "2.4.1",
+                "parseurl": "~1.3.3",
+                "statuses": "2.0.1",
+                "unpipe": "~1.0.0"
+            },
+            "engines": {
+                "node": ">= 0.8"
+            }
+        },
+        "node_modules/form-data": {
+            "version": "4.0.3",
+            "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz",
+            "integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==",
+            "dev": true,
+            "dependencies": {
+                "asynckit": "^0.4.0",
+                "combined-stream": "^1.0.8",
+                "es-set-tostringtag": "^2.1.0",
+                "hasown": "^2.0.2",
+                "mime-types": "^2.1.12"
+            },
+            "engines": {
+                "node": ">= 6"
+            }
+        },
+        "node_modules/forwarded": {
+            "version": "0.2.0",
+            "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+            "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+            "dev": true,
+            "engines": {
+                "node": ">= 0.6"
+            }
+        },
+        "node_modules/fresh": {
+            "version": "0.5.2",
+            "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+            "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+            "dev": true,
+            "engines": {
+                "node": ">= 0.6"
+            }
+        },
+        "node_modules/fsevents": {
+            "version": "2.3.2",
+            "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+            "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+            "dev": true,
+            "hasInstallScript": true,
+            "optional": true,
+            "os": [
+                "darwin"
+            ],
+            "engines": {
+                "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+            }
+        },
+        "node_modules/function-bind": {
+            "version": "1.1.2",
+            "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+            "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+            "dev": true,
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "node_modules/generate-function": {
+            "version": "2.3.1",
+            "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
+            "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
+            "dependencies": {
+                "is-property": "^1.0.2"
+            }
+        },
+        "node_modules/get-intrinsic": {
+            "version": "1.3.0",
+            "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+            "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+            "dev": true,
+            "dependencies": {
+                "call-bind-apply-helpers": "^1.0.2",
+                "es-define-property": "^1.0.1",
+                "es-errors": "^1.3.0",
+                "es-object-atoms": "^1.1.1",
+                "function-bind": "^1.1.2",
+                "get-proto": "^1.0.1",
+                "gopd": "^1.2.0",
+                "has-symbols": "^1.1.0",
+                "hasown": "^2.0.2",
+                "math-intrinsics": "^1.1.0"
+            },
+            "engines": {
+                "node": ">= 0.4"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "node_modules/get-proto": {
+            "version": "1.0.1",
+            "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+            "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+            "dev": true,
+            "dependencies": {
+                "dunder-proto": "^1.0.1",
+                "es-object-atoms": "^1.0.0"
+            },
+            "engines": {
+                "node": ">= 0.4"
+            }
+        },
+        "node_modules/gopd": {
+            "version": "1.2.0",
+            "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+            "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+            "dev": true,
+            "engines": {
+                "node": ">= 0.4"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "node_modules/has-symbols": {
+            "version": "1.1.0",
+            "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+            "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+            "dev": true,
+            "engines": {
+                "node": ">= 0.4"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "node_modules/has-tostringtag": {
+            "version": "1.0.2",
+            "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+            "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+            "dev": true,
+            "dependencies": {
+                "has-symbols": "^1.0.3"
+            },
+            "engines": {
+                "node": ">= 0.4"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "node_modules/hasown": {
+            "version": "2.0.2",
+            "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+            "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+            "dev": true,
+            "dependencies": {
+                "function-bind": "^1.1.2"
+            },
+            "engines": {
+                "node": ">= 0.4"
+            }
+        },
+        "node_modules/he": {
+            "version": "1.2.0",
+            "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
+            "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
+            "dev": true,
+            "bin": {
+                "he": "bin/he"
+            }
+        },
+        "node_modules/html-encoding-sniffer": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
+            "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
+            "dev": true,
+            "dependencies": {
+                "whatwg-encoding": "^3.1.1"
+            },
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/html-to-text": {
+            "version": "9.0.5",
+            "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
+            "integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==",
+            "dev": true,
+            "dependencies": {
+                "@selderee/plugin-htmlparser2": "^0.11.0",
+                "deepmerge": "^4.3.1",
+                "dom-serializer": "^2.0.0",
+                "htmlparser2": "^8.0.2",
+                "selderee": "^0.11.0"
+            },
+            "engines": {
+                "node": ">=14"
+            }
+        },
+        "node_modules/htmlparser2": {
+            "version": "8.0.2",
+            "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
+            "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
+            "dev": true,
+            "funding": [
+                "https://github.com/fb55/htmlparser2?sponsor=1",
+                {
+                    "type": "github",
+                    "url": "https://github.com/sponsors/fb55"
+                }
+            ],
+            "dependencies": {
+                "domelementtype": "^2.3.0",
+                "domhandler": "^5.0.3",
+                "domutils": "^3.0.1",
+                "entities": "^4.4.0"
+            }
+        },
+        "node_modules/http-errors": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
+            "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
+            "dev": true,
+            "dependencies": {
+                "depd": "2.0.0",
+                "inherits": "2.0.4",
+                "setprototypeof": "1.2.0",
+                "statuses": "2.0.1",
+                "toidentifier": "1.0.1"
+            },
+            "engines": {
+                "node": ">= 0.8"
+            }
+        },
+        "node_modules/http-proxy-agent": {
+            "version": "7.0.2",
+            "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+            "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
+            "dev": true,
+            "dependencies": {
+                "agent-base": "^7.1.0",
+                "debug": "^4.3.4"
+            },
+            "engines": {
+                "node": ">= 14"
+            }
+        },
+        "node_modules/http-proxy-agent/node_modules/debug": {
+            "version": "4.4.1",
+            "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
+            "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
+            "dev": true,
+            "dependencies": {
+                "ms": "^2.1.3"
+            },
+            "engines": {
+                "node": ">=6.0"
+            },
+            "peerDependenciesMeta": {
+                "supports-color": {
+                    "optional": true
+                }
+            }
+        },
+        "node_modules/http-proxy-agent/node_modules/ms": {
+            "version": "2.1.3",
+            "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+            "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+            "dev": true
+        },
+        "node_modules/https-proxy-agent": {
+            "version": "7.0.6",
+            "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+            "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+            "dev": true,
+            "dependencies": {
+                "agent-base": "^7.1.2",
+                "debug": "4"
+            },
+            "engines": {
+                "node": ">= 14"
+            }
+        },
+        "node_modules/https-proxy-agent/node_modules/debug": {
+            "version": "4.4.1",
+            "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
+            "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
+            "dev": true,
+            "dependencies": {
+                "ms": "^2.1.3"
+            },
+            "engines": {
+                "node": ">=6.0"
+            },
+            "peerDependenciesMeta": {
+                "supports-color": {
+                    "optional": true
+                }
+            }
+        },
+        "node_modules/https-proxy-agent/node_modules/ms": {
+            "version": "2.1.3",
+            "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+            "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+            "dev": true
+        },
+        "node_modules/iconv-lite": {
+            "version": "0.6.3",
+            "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+            "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+            "dependencies": {
+                "safer-buffer": ">= 2.1.2 < 3.0.0"
+            },
+            "engines": {
+                "node": ">=0.10.0"
+            }
+        },
+        "node_modules/inherits": {
+            "version": "2.0.4",
+            "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+            "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+            "dev": true
+        },
+        "node_modules/ipaddr.js": {
+            "version": "1.9.1",
+            "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+            "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+            "dev": true,
+            "engines": {
+                "node": ">= 0.10"
+            }
+        },
+        "node_modules/ipv6-normalize": {
+            "version": "1.0.1",
+            "resolved": "https://registry.npmjs.org/ipv6-normalize/-/ipv6-normalize-1.0.1.tgz",
+            "integrity": "sha512-Bm6H79i01DjgGTCWjUuCjJ6QDo1HB96PT/xCYuyJUP9WFbVDrLSbG4EZCvOCun2rNswZb0c3e4Jt/ws795esHA==",
+            "dev": true
+        },
+        "node_modules/is-potential-custom-element-name": {
+            "version": "1.0.1",
+            "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+            "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+            "dev": true
+        },
+        "node_modules/is-property": {
+            "version": "1.0.2",
+            "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
+            "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="
+        },
+        "node_modules/jsdom": {
+            "version": "24.1.3",
+            "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.1.3.tgz",
+            "integrity": "sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==",
+            "dev": true,
+            "dependencies": {
+                "cssstyle": "^4.0.1",
+                "data-urls": "^5.0.0",
+                "decimal.js": "^10.4.3",
+                "form-data": "^4.0.0",
+                "html-encoding-sniffer": "^4.0.0",
+                "http-proxy-agent": "^7.0.2",
+                "https-proxy-agent": "^7.0.5",
+                "is-potential-custom-element-name": "^1.0.1",
+                "nwsapi": "^2.2.12",
+                "parse5": "^7.1.2",
+                "rrweb-cssom": "^0.7.1",
+                "saxes": "^6.0.0",
+                "symbol-tree": "^3.2.4",
+                "tough-cookie": "^4.1.4",
+                "w3c-xmlserializer": "^5.0.0",
+                "webidl-conversions": "^7.0.0",
+                "whatwg-encoding": "^3.1.1",
+                "whatwg-mimetype": "^4.0.0",
+                "whatwg-url": "^14.0.0",
+                "ws": "^8.18.0",
+                "xml-name-validator": "^5.0.0"
+            },
+            "engines": {
+                "node": ">=18"
+            },
+            "peerDependencies": {
+                "canvas": "^2.11.2"
+            },
+            "peerDependenciesMeta": {
+                "canvas": {
+                    "optional": true
+                }
+            }
+        },
+        "node_modules/leac": {
+            "version": "0.6.0",
+            "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz",
+            "integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==",
+            "dev": true,
+            "funding": {
+                "url": "https://ko-fi.com/killymxi"
+            }
+        },
+        "node_modules/libbase64": {
+            "version": "1.3.0",
+            "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz",
+            "integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==",
+            "dev": true
+        },
+        "node_modules/libmime": {
+            "version": "5.3.6",
+            "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.6.tgz",
+            "integrity": "sha512-j9mBC7eiqi6fgBPAGvKCXJKJSIASanYF4EeA4iBzSG0HxQxmXnR3KbyWqTn4CwsKSebqCv2f5XZfAO6sKzgvwA==",
+            "dev": true,
+            "dependencies": {
+                "encoding-japanese": "2.2.0",
+                "iconv-lite": "0.6.3",
+                "libbase64": "1.3.0",
+                "libqp": "2.1.1"
+            }
+        },
+        "node_modules/libqp": {
+            "version": "2.1.1",
+            "resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz",
+            "integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==",
+            "dev": true
+        },
+        "node_modules/linkify-it": {
+            "version": "5.0.0",
+            "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
+            "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
+            "dev": true,
+            "dependencies": {
+                "uc.micro": "^2.0.0"
+            }
+        },
+        "node_modules/long": {
+            "version": "5.3.2",
+            "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
+            "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="
+        },
+        "node_modules/lru-cache": {
+            "version": "10.4.3",
+            "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+            "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+            "dev": true
+        },
+        "node_modules/lru.min": {
+            "version": "1.1.2",
+            "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.2.tgz",
+            "integrity": "sha512-Nv9KddBcQSlQopmBHXSsZVY5xsdlZkdH/Iey0BlcBYggMd4two7cZnKOK9vmy3nY0O5RGH99z1PCeTpPqszUYg==",
+            "engines": {
+                "bun": ">=1.0.0",
+                "deno": ">=1.30.0",
+                "node": ">=8.0.0"
+            },
+            "funding": {
+                "type": "github",
+                "url": "https://github.com/sponsors/wellwelwel"
+            }
+        },
+        "node_modules/maildev": {
+            "name": "@timshel_npm/maildev",
+            "version": "3.1.2",
+            "resolved": "https://registry.npmjs.org/@timshel_npm/maildev/-/maildev-3.1.2.tgz",
+            "integrity": "sha512-AQ6vu7g7K+x/6zFrvKGR9CfJjKNhJI8dhY1wfQr/6tbidADixN7NlY1HK0A6RGE+IW8VghWR5amWWGAabBIELw==",
+            "dev": true,
+            "dependencies": {
+                "@types/mailparser": "^3.4.6",
+                "addressparser": "1.0.1",
+                "async": "^3.2.6",
+                "commander": "^12.1.0",
+                "compression": "^1.8.0",
+                "cors": "^2.8.5",
+                "dompurify": "^3.2.6",
+                "express": "^4.21.2",
+                "jsdom": "^24.1.3",
+                "mailparser": "^3.7.3",
+                "mime": "1.6.0",
+                "nodemailer": "^6.10.1",
+                "smtp-server": "^3.13.8",
+                "socket.io": "^4.8.1",
+                "wildstring": "1.0.9"
+            },
+            "bin": {
+                "maildev": "bin/maildev"
+            },
+            "engines": {
+                "node": ">=18.0.0"
+            }
+        },
+        "node_modules/mailparser": {
+            "version": "3.7.3",
+            "resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.7.3.tgz",
+            "integrity": "sha512-0RM14cZF0gO1y2Q/82hhWranispZOUSYHwvQ21h12x90NwD6+D5q59S5nOLqCtCdYitHN58LJXWEHa4RWm7BYA==",
+            "dev": true,
+            "dependencies": {
+                "encoding-japanese": "2.2.0",
+                "he": "1.2.0",
+                "html-to-text": "9.0.5",
+                "iconv-lite": "0.6.3",
+                "libmime": "5.3.6",
+                "linkify-it": "5.0.0",
+                "mailsplit": "5.4.3",
+                "nodemailer": "7.0.3",
+                "punycode.js": "2.3.1",
+                "tlds": "1.259.0"
+            }
+        },
+        "node_modules/mailparser/node_modules/nodemailer": {
+            "version": "7.0.3",
+            "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.3.tgz",
+            "integrity": "sha512-Ajq6Sz1x7cIK3pN6KesGTah+1gnwMnx5gKl3piQlQQE/PwyJ4Mbc8is2psWYxK3RJTVeqsDaCv8ZzXLCDHMTZw==",
+            "dev": true,
+            "engines": {
+                "node": ">=6.0.0"
+            }
+        },
+        "node_modules/mailsplit": {
+            "version": "5.4.3",
+            "resolved": "https://registry.npmjs.org/mailsplit/-/mailsplit-5.4.3.tgz",
+            "integrity": "sha512-PFV0BBh4Tv7Omui5FtXXVtN4ExAxIi8Yvmb9JgBz+J6Hnnrv/YYXLlKKudLhXwd3/qWEATOslRsnzVCWDeCnmQ==",
+            "dev": true,
+            "dependencies": {
+                "libbase64": "1.3.0",
+                "libmime": "5.3.6",
+                "libqp": "2.1.1"
+            }
+        },
+        "node_modules/math-intrinsics": {
+            "version": "1.1.0",
+            "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+            "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+            "dev": true,
+            "engines": {
+                "node": ">= 0.4"
+            }
+        },
+        "node_modules/media-typer": {
+            "version": "0.3.0",
+            "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+            "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+            "dev": true,
+            "engines": {
+                "node": ">= 0.6"
+            }
+        },
+        "node_modules/merge-descriptors": {
+            "version": "1.0.3",
+            "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+            "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+            "dev": true,
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
+            }
+        },
+        "node_modules/methods": {
+            "version": "1.1.2",
+            "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+            "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+            "dev": true,
+            "engines": {
+                "node": ">= 0.6"
+            }
+        },
+        "node_modules/mime": {
+            "version": "1.6.0",
+            "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+            "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+            "dev": true,
+            "bin": {
+                "mime": "cli.js"
+            },
+            "engines": {
+                "node": ">=4"
+            }
+        },
+        "node_modules/mime-db": {
+            "version": "1.54.0",
+            "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+            "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+            "dev": true,
+            "engines": {
+                "node": ">= 0.6"
+            }
+        },
+        "node_modules/mime-types": {
+            "version": "2.1.35",
+            "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+            "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+            "dev": true,
+            "dependencies": {
+                "mime-db": "1.52.0"
+            },
+            "engines": {
+                "node": ">= 0.6"
+            }
+        },
+        "node_modules/mime-types/node_modules/mime-db": {
+            "version": "1.52.0",
+            "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+            "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+            "dev": true,
+            "engines": {
+                "node": ">= 0.6"
+            }
+        },
+        "node_modules/ms": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+            "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+            "dev": true
+        },
+        "node_modules/mysql2": {
+            "version": "3.14.1",
+            "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.14.1.tgz",
+            "integrity": "sha512-7ytuPQJjQB8TNAYX/H2yhL+iQOnIBjAMam361R7UAL0lOVXWjtdrmoL9HYKqKoLp/8UUTRcvo1QPvK9KL7wA8w==",
+            "dependencies": {
+                "aws-ssl-profiles": "^1.1.1",
+                "denque": "^2.1.0",
+                "generate-function": "^2.3.1",
+                "iconv-lite": "^0.6.3",
+                "long": "^5.2.1",
+                "lru.min": "^1.0.0",
+                "named-placeholders": "^1.1.3",
+                "seq-queue": "^0.0.5",
+                "sqlstring": "^2.3.2"
+            },
+            "engines": {
+                "node": ">= 8.0"
+            }
+        },
+        "node_modules/named-placeholders": {
+            "version": "1.1.3",
+            "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.3.tgz",
+            "integrity": "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==",
+            "dependencies": {
+                "lru-cache": "^7.14.1"
+            },
+            "engines": {
+                "node": ">=12.0.0"
+            }
+        },
+        "node_modules/named-placeholders/node_modules/lru-cache": {
+            "version": "7.18.3",
+            "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",
+            "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==",
+            "engines": {
+                "node": ">=12"
+            }
+        },
+        "node_modules/negotiator": {
+            "version": "0.6.4",
+            "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
+            "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
+            "dev": true,
+            "engines": {
+                "node": ">= 0.6"
+            }
+        },
+        "node_modules/nodemailer": {
+            "version": "6.10.1",
+            "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
+            "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
+            "dev": true,
+            "engines": {
+                "node": ">=6.0.0"
+            }
+        },
+        "node_modules/nwsapi": {
+            "version": "2.2.20",
+            "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.20.tgz",
+            "integrity": "sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==",
+            "dev": true
+        },
+        "node_modules/object-assign": {
+            "version": "4.1.1",
+            "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+            "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+            "dev": true,
+            "engines": {
+                "node": ">=0.10.0"
+            }
+        },
+        "node_modules/object-inspect": {
+            "version": "1.13.4",
+            "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+            "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+            "dev": true,
+            "engines": {
+                "node": ">= 0.4"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "node_modules/on-finished": {
+            "version": "2.4.1",
+            "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+            "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+            "dev": true,
+            "dependencies": {
+                "ee-first": "1.1.1"
+            },
+            "engines": {
+                "node": ">= 0.8"
+            }
+        },
+        "node_modules/on-headers": {
+            "version": "1.0.2",
+            "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz",
+            "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==",
+            "dev": true,
+            "engines": {
+                "node": ">= 0.8"
+            }
+        },
+        "node_modules/otpauth": {
+            "version": "9.4.0",
+            "resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.4.0.tgz",
+            "integrity": "sha512-fHIfzIG5RqCkK9cmV8WU+dPQr9/ebR5QOwGZn2JAr1RQF+lmAuLL2YdtdqvmBjNmgJlYk3KZ4a0XokaEhg1Jsw==",
+            "dependencies": {
+                "@noble/hashes": "1.7.1"
+            },
+            "funding": {
+                "url": "https://github.com/hectorm/otpauth?sponsor=1"
+            }
+        },
+        "node_modules/parse5": {
+            "version": "7.3.0",
+            "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
+            "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
+            "dev": true,
+            "dependencies": {
+                "entities": "^6.0.0"
+            },
+            "funding": {
+                "url": "https://github.com/inikulin/parse5?sponsor=1"
+            }
+        },
+        "node_modules/parse5/node_modules/entities": {
+            "version": "6.0.1",
+            "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+            "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+            "dev": true,
+            "engines": {
+                "node": ">=0.12"
+            },
+            "funding": {
+                "url": "https://github.com/fb55/entities?sponsor=1"
+            }
+        },
+        "node_modules/parseley": {
+            "version": "0.12.1",
+            "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz",
+            "integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==",
+            "dev": true,
+            "dependencies": {
+                "leac": "^0.6.0",
+                "peberminta": "^0.9.0"
+            },
+            "funding": {
+                "url": "https://ko-fi.com/killymxi"
+            }
+        },
+        "node_modules/parseurl": {
+            "version": "1.3.3",
+            "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+            "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+            "dev": true,
+            "engines": {
+                "node": ">= 0.8"
+            }
+        },
+        "node_modules/path-to-regexp": {
+            "version": "0.1.12",
+            "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
+            "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
+            "dev": true
+        },
+        "node_modules/peberminta": {
+            "version": "0.9.0",
+            "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz",
+            "integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==",
+            "dev": true,
+            "funding": {
+                "url": "https://ko-fi.com/killymxi"
+            }
+        },
+        "node_modules/pg": {
+            "version": "8.16.0",
+            "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.0.tgz",
+            "integrity": "sha512-7SKfdvP8CTNXjMUzfcVTaI+TDzBEeaUnVwiVGZQD1Hh33Kpev7liQba9uLd4CfN8r9mCVsD0JIpq03+Unpz+kg==",
+            "dependencies": {
+                "pg-connection-string": "^2.9.0",
+                "pg-pool": "^3.10.0",
+                "pg-protocol": "^1.10.0",
+                "pg-types": "2.2.0",
+                "pgpass": "1.0.5"
+            },
+            "engines": {
+                "node": ">= 8.0.0"
+            },
+            "optionalDependencies": {
+                "pg-cloudflare": "^1.2.5"
+            },
+            "peerDependencies": {
+                "pg-native": ">=3.0.1"
+            },
+            "peerDependenciesMeta": {
+                "pg-native": {
+                    "optional": true
+                }
+            }
+        },
+        "node_modules/pg-cloudflare": {
+            "version": "1.2.5",
+            "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.5.tgz",
+            "integrity": "sha512-OOX22Vt0vOSRrdoUPKJ8Wi2OpE/o/h9T8X1s4qSkCedbNah9ei2W2765be8iMVxQUsvgT7zIAT2eIa9fs5+vtg==",
+            "optional": true
+        },
+        "node_modules/pg-connection-string": {
+            "version": "2.9.0",
+            "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.0.tgz",
+            "integrity": "sha512-P2DEBKuvh5RClafLngkAuGe9OUlFV7ebu8w1kmaaOgPcpJd1RIFh7otETfI6hAR8YupOLFTY7nuvvIn7PLciUQ=="
+        },
+        "node_modules/pg-int8": {
+            "version": "1.0.1",
+            "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+            "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+            "engines": {
+                "node": ">=4.0.0"
+            }
+        },
+        "node_modules/pg-pool": {
+            "version": "3.10.0",
+            "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.0.tgz",
+            "integrity": "sha512-DzZ26On4sQ0KmqnO34muPcmKbhrjmyiO4lCCR0VwEd7MjmiKf5NTg/6+apUEu0NF7ESa37CGzFxH513CoUmWnA==",
+            "peerDependencies": {
+                "pg": ">=8.0"
+            }
+        },
+        "node_modules/pg-protocol": {
+            "version": "1.10.0",
+            "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.0.tgz",
+            "integrity": "sha512-IpdytjudNuLv8nhlHs/UrVBhU0e78J0oIS/0AVdTbWxSOkFUVdsHC/NrorO6nXsQNDTT1kzDSOMJubBQviX18Q=="
+        },
+        "node_modules/pg-types": {
+            "version": "2.2.0",
+            "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+            "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+            "dependencies": {
+                "pg-int8": "1.0.1",
+                "postgres-array": "~2.0.0",
+                "postgres-bytea": "~1.0.0",
+                "postgres-date": "~1.0.4",
+                "postgres-interval": "^1.1.0"
+            },
+            "engines": {
+                "node": ">=4"
+            }
+        },
+        "node_modules/pgpass": {
+            "version": "1.0.5",
+            "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+            "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+            "dependencies": {
+                "split2": "^4.1.0"
+            }
+        },
+        "node_modules/playwright": {
+            "version": "1.53.0",
+            "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.0.tgz",
+            "integrity": "sha512-ghGNnIEYZC4E+YtclRn4/p6oYbdPiASELBIYkBXfaTVKreQUYbMUYQDwS12a8F0/HtIjr/CkGjtwABeFPGcS4Q==",
+            "dev": true,
+            "dependencies": {
+                "playwright-core": "1.53.0"
+            },
+            "bin": {
+                "playwright": "cli.js"
+            },
+            "engines": {
+                "node": ">=18"
+            },
+            "optionalDependencies": {
+                "fsevents": "2.3.2"
+            }
+        },
+        "node_modules/playwright-core": {
+            "version": "1.53.0",
+            "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.0.tgz",
+            "integrity": "sha512-mGLg8m0pm4+mmtB7M89Xw/GSqoNC+twivl8ITteqvAndachozYe2ZA7srU6uleV1vEdAHYqjq+SV8SNxRRFYBw==",
+            "dev": true,
+            "bin": {
+                "playwright-core": "cli.js"
+            },
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/postgres-array": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+            "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+            "engines": {
+                "node": ">=4"
+            }
+        },
+        "node_modules/postgres-bytea": {
+            "version": "1.0.0",
+            "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz",
+            "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==",
+            "engines": {
+                "node": ">=0.10.0"
+            }
+        },
+        "node_modules/postgres-date": {
+            "version": "1.0.7",
+            "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+            "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+            "engines": {
+                "node": ">=0.10.0"
+            }
+        },
+        "node_modules/postgres-interval": {
+            "version": "1.2.0",
+            "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+            "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+            "dependencies": {
+                "xtend": "^4.0.0"
+            },
+            "engines": {
+                "node": ">=0.10.0"
+            }
+        },
+        "node_modules/proxy-addr": {
+            "version": "2.0.7",
+            "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+            "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+            "dev": true,
+            "dependencies": {
+                "forwarded": "0.2.0",
+                "ipaddr.js": "1.9.1"
+            },
+            "engines": {
+                "node": ">= 0.10"
+            }
+        },
+        "node_modules/psl": {
+            "version": "1.15.0",
+            "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
+            "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
+            "dev": true,
+            "dependencies": {
+                "punycode": "^2.3.1"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/lupomontero"
+            }
+        },
+        "node_modules/punycode": {
+            "version": "2.3.1",
+            "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+            "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+            "dev": true,
+            "engines": {
+                "node": ">=6"
+            }
+        },
+        "node_modules/punycode.js": {
+            "version": "2.3.1",
+            "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
+            "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
+            "dev": true,
+            "engines": {
+                "node": ">=6"
+            }
+        },
+        "node_modules/qs": {
+            "version": "6.13.0",
+            "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
+            "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
+            "dev": true,
+            "dependencies": {
+                "side-channel": "^1.0.6"
+            },
+            "engines": {
+                "node": ">=0.6"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "node_modules/querystringify": {
+            "version": "2.2.0",
+            "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
+            "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
+            "dev": true
+        },
+        "node_modules/range-parser": {
+            "version": "1.2.1",
+            "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+            "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+            "dev": true,
+            "engines": {
+                "node": ">= 0.6"
+            }
+        },
+        "node_modules/raw-body": {
+            "version": "2.5.2",
+            "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
+            "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
+            "dev": true,
+            "dependencies": {
+                "bytes": "3.1.2",
+                "http-errors": "2.0.0",
+                "iconv-lite": "0.4.24",
+                "unpipe": "1.0.0"
+            },
+            "engines": {
+                "node": ">= 0.8"
+            }
+        },
+        "node_modules/raw-body/node_modules/iconv-lite": {
+            "version": "0.4.24",
+            "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+            "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+            "dev": true,
+            "dependencies": {
+                "safer-buffer": ">= 2.1.2 < 3"
+            },
+            "engines": {
+                "node": ">=0.10.0"
+            }
+        },
+        "node_modules/requires-port": {
+            "version": "1.0.0",
+            "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
+            "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
+            "dev": true
+        },
+        "node_modules/rrweb-cssom": {
+            "version": "0.7.1",
+            "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz",
+            "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==",
+            "dev": true
+        },
+        "node_modules/safe-buffer": {
+            "version": "5.2.1",
+            "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+            "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+            "dev": true,
+            "funding": [
+                {
+                    "type": "github",
+                    "url": "https://github.com/sponsors/feross"
+                },
+                {
+                    "type": "patreon",
+                    "url": "https://www.patreon.com/feross"
+                },
+                {
+                    "type": "consulting",
+                    "url": "https://feross.org/support"
+                }
+            ]
+        },
+        "node_modules/safer-buffer": {
+            "version": "2.1.2",
+            "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+            "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
+        },
+        "node_modules/saxes": {
+            "version": "6.0.0",
+            "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+            "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+            "dev": true,
+            "dependencies": {
+                "xmlchars": "^2.2.0"
+            },
+            "engines": {
+                "node": ">=v12.22.7"
+            }
+        },
+        "node_modules/selderee": {
+            "version": "0.11.0",
+            "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz",
+            "integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==",
+            "dev": true,
+            "dependencies": {
+                "parseley": "^0.12.0"
+            },
+            "funding": {
+                "url": "https://ko-fi.com/killymxi"
+            }
+        },
+        "node_modules/send": {
+            "version": "0.19.0",
+            "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
+            "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==",
+            "dev": true,
+            "dependencies": {
+                "debug": "2.6.9",
+                "depd": "2.0.0",
+                "destroy": "1.2.0",
+                "encodeurl": "~1.0.2",
+                "escape-html": "~1.0.3",
+                "etag": "~1.8.1",
+                "fresh": "0.5.2",
+                "http-errors": "2.0.0",
+                "mime": "1.6.0",
+                "ms": "2.1.3",
+                "on-finished": "2.4.1",
+                "range-parser": "~1.2.1",
+                "statuses": "2.0.1"
+            },
+            "engines": {
+                "node": ">= 0.8.0"
+            }
+        },
+        "node_modules/send/node_modules/encodeurl": {
+            "version": "1.0.2",
+            "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
+            "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
+            "dev": true,
+            "engines": {
+                "node": ">= 0.8"
+            }
+        },
+        "node_modules/send/node_modules/ms": {
+            "version": "2.1.3",
+            "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+            "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+            "dev": true
+        },
+        "node_modules/seq-queue": {
+            "version": "0.0.5",
+            "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz",
+            "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="
+        },
+        "node_modules/serve-static": {
+            "version": "1.16.2",
+            "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
+            "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==",
+            "dev": true,
+            "dependencies": {
+                "encodeurl": "~2.0.0",
+                "escape-html": "~1.0.3",
+                "parseurl": "~1.3.3",
+                "send": "0.19.0"
+            },
+            "engines": {
+                "node": ">= 0.8.0"
+            }
+        },
+        "node_modules/setprototypeof": {
+            "version": "1.2.0",
+            "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+            "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+            "dev": true
+        },
+        "node_modules/side-channel": {
+            "version": "1.1.0",
+            "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+            "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+            "dev": true,
+            "dependencies": {
+                "es-errors": "^1.3.0",
+                "object-inspect": "^1.13.3",
+                "side-channel-list": "^1.0.0",
+                "side-channel-map": "^1.0.1",
+                "side-channel-weakmap": "^1.0.2"
+            },
+            "engines": {
+                "node": ">= 0.4"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "node_modules/side-channel-list": {
+            "version": "1.0.0",
+            "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
+            "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
+            "dev": true,
+            "dependencies": {
+                "es-errors": "^1.3.0",
+                "object-inspect": "^1.13.3"
+            },
+            "engines": {
+                "node": ">= 0.4"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "node_modules/side-channel-map": {
+            "version": "1.0.1",
+            "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+            "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+            "dev": true,
+            "dependencies": {
+                "call-bound": "^1.0.2",
+                "es-errors": "^1.3.0",
+                "get-intrinsic": "^1.2.5",
+                "object-inspect": "^1.13.3"
+            },
+            "engines": {
+                "node": ">= 0.4"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "node_modules/side-channel-weakmap": {
+            "version": "1.0.2",
+            "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+            "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+            "dev": true,
+            "dependencies": {
+                "call-bound": "^1.0.2",
+                "es-errors": "^1.3.0",
+                "get-intrinsic": "^1.2.5",
+                "object-inspect": "^1.13.3",
+                "side-channel-map": "^1.0.1"
+            },
+            "engines": {
+                "node": ">= 0.4"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "node_modules/smtp-server": {
+            "version": "3.13.8",
+            "resolved": "https://registry.npmjs.org/smtp-server/-/smtp-server-3.13.8.tgz",
+            "integrity": "sha512-bKYZ/ortxX8Wvi7bCT/daoo1aS1BI1CNoWkonXOLXtWhSccWmBikcMlbpHSzUjmv+vNZQDfOv4b55mhpjPlSsg==",
+            "dev": true,
+            "dependencies": {
+                "base32.js": "0.1.0",
+                "ipv6-normalize": "1.0.1",
+                "nodemailer": "7.0.3",
+                "punycode.js": "2.3.1"
+            },
+            "engines": {
+                "node": ">=12.0.0"
+            }
+        },
+        "node_modules/smtp-server/node_modules/nodemailer": {
+            "version": "7.0.3",
+            "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.3.tgz",
+            "integrity": "sha512-Ajq6Sz1x7cIK3pN6KesGTah+1gnwMnx5gKl3piQlQQE/PwyJ4Mbc8is2psWYxK3RJTVeqsDaCv8ZzXLCDHMTZw==",
+            "dev": true,
+            "engines": {
+                "node": ">=6.0.0"
+            }
+        },
+        "node_modules/socket.io": {
+            "version": "4.8.1",
+            "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz",
+            "integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==",
+            "dev": true,
+            "dependencies": {
+                "accepts": "~1.3.4",
+                "base64id": "~2.0.0",
+                "cors": "~2.8.5",
+                "debug": "~4.3.2",
+                "engine.io": "~6.6.0",
+                "socket.io-adapter": "~2.5.2",
+                "socket.io-parser": "~4.2.4"
+            },
+            "engines": {
+                "node": ">=10.2.0"
+            }
+        },
+        "node_modules/socket.io-adapter": {
+            "version": "2.5.5",
+            "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz",
+            "integrity": "sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==",
+            "dev": true,
+            "dependencies": {
+                "debug": "~4.3.4",
+                "ws": "~8.17.1"
+            }
+        },
+        "node_modules/socket.io-adapter/node_modules/debug": {
+            "version": "4.3.7",
+            "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
+            "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
+            "dev": true,
+            "dependencies": {
+                "ms": "^2.1.3"
+            },
+            "engines": {
+                "node": ">=6.0"
+            },
+            "peerDependenciesMeta": {
+                "supports-color": {
+                    "optional": true
+                }
+            }
+        },
+        "node_modules/socket.io-adapter/node_modules/ms": {
+            "version": "2.1.3",
+            "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+            "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+            "dev": true
+        },
+        "node_modules/socket.io-adapter/node_modules/ws": {
+            "version": "8.17.1",
+            "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
+            "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
+            "dev": true,
+            "engines": {
+                "node": ">=10.0.0"
+            },
+            "peerDependencies": {
+                "bufferutil": "^4.0.1",
+                "utf-8-validate": ">=5.0.2"
+            },
+            "peerDependenciesMeta": {
+                "bufferutil": {
+                    "optional": true
+                },
+                "utf-8-validate": {
+                    "optional": true
+                }
+            }
+        },
+        "node_modules/socket.io-parser": {
+            "version": "4.2.4",
+            "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
+            "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
+            "dev": true,
+            "dependencies": {
+                "@socket.io/component-emitter": "~3.1.0",
+                "debug": "~4.3.1"
+            },
+            "engines": {
+                "node": ">=10.0.0"
+            }
+        },
+        "node_modules/socket.io-parser/node_modules/debug": {
+            "version": "4.3.7",
+            "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
+            "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
+            "dev": true,
+            "dependencies": {
+                "ms": "^2.1.3"
+            },
+            "engines": {
+                "node": ">=6.0"
+            },
+            "peerDependenciesMeta": {
+                "supports-color": {
+                    "optional": true
+                }
+            }
+        },
+        "node_modules/socket.io-parser/node_modules/ms": {
+            "version": "2.1.3",
+            "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+            "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+            "dev": true
+        },
+        "node_modules/socket.io/node_modules/debug": {
+            "version": "4.3.7",
+            "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
+            "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
+            "dev": true,
+            "dependencies": {
+                "ms": "^2.1.3"
+            },
+            "engines": {
+                "node": ">=6.0"
+            },
+            "peerDependenciesMeta": {
+                "supports-color": {
+                    "optional": true
+                }
+            }
+        },
+        "node_modules/socket.io/node_modules/ms": {
+            "version": "2.1.3",
+            "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+            "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+            "dev": true
+        },
+        "node_modules/split2": {
+            "version": "4.2.0",
+            "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+            "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+            "engines": {
+                "node": ">= 10.x"
+            }
+        },
+        "node_modules/sqlstring": {
+            "version": "2.3.3",
+            "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
+            "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==",
+            "engines": {
+                "node": ">= 0.6"
+            }
+        },
+        "node_modules/statuses": {
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
+            "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
+            "dev": true,
+            "engines": {
+                "node": ">= 0.8"
+            }
+        },
+        "node_modules/symbol-tree": {
+            "version": "3.2.4",
+            "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+            "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+            "dev": true
+        },
+        "node_modules/tlds": {
+            "version": "1.259.0",
+            "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.259.0.tgz",
+            "integrity": "sha512-AldGGlDP0PNgwppe2quAvuBl18UcjuNtOnDuUkqhd6ipPqrYYBt3aTxK1QTsBVknk97lS2JcafWMghjGWFtunw==",
+            "dev": true,
+            "bin": {
+                "tlds": "bin.js"
+            }
+        },
+        "node_modules/toidentifier": {
+            "version": "1.0.1",
+            "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+            "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+            "dev": true,
+            "engines": {
+                "node": ">=0.6"
+            }
+        },
+        "node_modules/tough-cookie": {
+            "version": "4.1.4",
+            "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
+            "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==",
+            "dev": true,
+            "dependencies": {
+                "psl": "^1.1.33",
+                "punycode": "^2.1.1",
+                "universalify": "^0.2.0",
+                "url-parse": "^1.5.3"
+            },
+            "engines": {
+                "node": ">=6"
+            }
+        },
+        "node_modules/tr46": {
+            "version": "5.1.1",
+            "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
+            "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
+            "dev": true,
+            "dependencies": {
+                "punycode": "^2.3.1"
+            },
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/type-is": {
+            "version": "1.6.18",
+            "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+            "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+            "dev": true,
+            "dependencies": {
+                "media-typer": "0.3.0",
+                "mime-types": "~2.1.24"
+            },
+            "engines": {
+                "node": ">= 0.6"
+            }
+        },
+        "node_modules/uc.micro": {
+            "version": "2.1.0",
+            "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
+            "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
+            "dev": true
+        },
+        "node_modules/undici-types": {
+            "version": "7.8.0",
+            "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz",
+            "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==",
+            "dev": true
+        },
+        "node_modules/universalify": {
+            "version": "0.2.0",
+            "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
+            "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
+            "dev": true,
+            "engines": {
+                "node": ">= 4.0.0"
+            }
+        },
+        "node_modules/unpipe": {
+            "version": "1.0.0",
+            "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+            "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+            "dev": true,
+            "engines": {
+                "node": ">= 0.8"
+            }
+        },
+        "node_modules/url-parse": {
+            "version": "1.5.10",
+            "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
+            "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
+            "dev": true,
+            "dependencies": {
+                "querystringify": "^2.1.1",
+                "requires-port": "^1.0.0"
+            }
+        },
+        "node_modules/utils-merge": {
+            "version": "1.0.1",
+            "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+            "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+            "dev": true,
+            "engines": {
+                "node": ">= 0.4.0"
+            }
+        },
+        "node_modules/vary": {
+            "version": "1.1.2",
+            "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+            "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+            "dev": true,
+            "engines": {
+                "node": ">= 0.8"
+            }
+        },
+        "node_modules/w3c-xmlserializer": {
+            "version": "5.0.0",
+            "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+            "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+            "dev": true,
+            "dependencies": {
+                "xml-name-validator": "^5.0.0"
+            },
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/webidl-conversions": {
+            "version": "7.0.0",
+            "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
+            "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
+            "dev": true,
+            "engines": {
+                "node": ">=12"
+            }
+        },
+        "node_modules/whatwg-encoding": {
+            "version": "3.1.1",
+            "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
+            "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
+            "dev": true,
+            "dependencies": {
+                "iconv-lite": "0.6.3"
+            },
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/whatwg-mimetype": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
+            "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
+            "dev": true,
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/whatwg-url": {
+            "version": "14.2.0",
+            "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
+            "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
+            "dev": true,
+            "dependencies": {
+                "tr46": "^5.1.0",
+                "webidl-conversions": "^7.0.0"
+            },
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/wildstring": {
+            "version": "1.0.9",
+            "resolved": "https://registry.npmjs.org/wildstring/-/wildstring-1.0.9.tgz",
+            "integrity": "sha512-XBNxKIMLO6uVHf1Xvo++HGWAZZoiVCHmEMCmZJzJ82vQsuUJCLw13Gzq0mRCATk7a3+ZcgeOKSDioavuYqtlfA==",
+            "dev": true
+        },
+        "node_modules/ws": {
+            "version": "8.18.2",
+            "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz",
+            "integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==",
+            "dev": true,
+            "engines": {
+                "node": ">=10.0.0"
+            },
+            "peerDependencies": {
+                "bufferutil": "^4.0.1",
+                "utf-8-validate": ">=5.0.2"
+            },
+            "peerDependenciesMeta": {
+                "bufferutil": {
+                    "optional": true
+                },
+                "utf-8-validate": {
+                    "optional": true
+                }
+            }
+        },
+        "node_modules/xml-name-validator": {
+            "version": "5.0.0",
+            "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+            "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+            "dev": true,
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/xmlchars": {
+            "version": "2.2.0",
+            "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+            "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+            "dev": true
+        },
+        "node_modules/xtend": {
+            "version": "4.0.2",
+            "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+            "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+            "engines": {
+                "node": ">=0.4"
+            }
+        }
+    }
+}

+ 21 - 0
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 - 0
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 - 0
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}
[email protected]
+
+#####################
+# 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 - 0
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 - 0
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 - 0
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 - 0
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 - 0
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 - 0
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 - 0
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 - 0
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 - 0
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 - 0
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 - 0
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 - 0
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 - 0
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 - 0
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 - 0
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 - 0
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 - 0
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 - 0
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();
+    });
+});

+ 30 - 3
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?;

+ 98 - 10
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,
@@ -104,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 {
@@ -244,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()
@@ -266,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")
             }
@@ -325,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)
@@ -1129,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))
 }
 

+ 1 - 1
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)
         }

+ 49 - 2
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(())
+}

+ 112 - 61
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;

+ 7 - 7
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
                 {

+ 10 - 11
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")
     }

+ 391 - 95
src/api/identity.rs

@@ -1,8 +1,10 @@
-use chrono::Utc;
+use chrono::{NaiveDateTime, Utc};
 use num_traits::FromPrimitive;
-use rocket::serde::json::Json;
 use rocket::{
     form::{Form, FromForm},
+    http::Status,
+    response::Redirect,
+    serde::json::Json,
     Route,
 };
 use serde_json::Value;
@@ -10,7 +12,7 @@ use serde_json::Value;
 use crate::{
     api::{
         core::{
-            accounts::{PreloginData, RegisterData, _prelogin, _register},
+            accounts::{PreloginData, RegisterData, _prelogin, _register, kdf_upgrade},
             log_user_event,
             two_factor::{authenticator, duo, duo_oidc, email, enforce_2fa_policy, webauthn, yubikey},
         },
@@ -18,14 +20,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>")]
@@ -42,8 +57,9 @@ async fn login(
     let login_result = match data.grant_type.as_ref() {
         "refresh_token" => {
             _check_is_some(&data.refresh_token, "refresh_token cannot be blank")?;
-            _refresh_login(data, &mut conn).await
+            _refresh_login(data, &mut conn, &client_header.ip).await
         }
+        "password" if CONFIG.sso_enabled() && CONFIG.sso_only() => err!("SSO sign-in is required"),
         "password" => {
             _check_is_some(&data.client_id, "client_id cannot be blank")?;
             _check_is_some(&data.password, "password cannot be blank")?;
@@ -67,6 +83,17 @@ async fn login(
 
             _api_key_login(data, &mut user_id, &mut conn, &client_header.ip).await
         }
+        "authorization_code" if CONFIG.sso_enabled() => {
+            _check_is_some(&data.client_id, "client_id cannot be blank")?;
+            _check_is_some(&data.code, "code cannot be blank")?;
+
+            _check_is_some(&data.device_identifier, "device_identifier cannot be blank")?;
+            _check_is_some(&data.device_name, "device_name cannot be blank")?;
+            _check_is_some(&data.device_type, "device_type cannot be blank")?;
+
+            _sso_login(data, &mut user_id, &mut conn, &client_header.ip, &client_version).await
+        }
+        "authorization_code" => err!("SSO sign-in is not available"),
         t => err!("Invalid type", t),
     };
 
@@ -100,37 +127,193 @@ async fn login(
     login_result
 }
 
-async fn _refresh_login(data: ConnectData, conn: &mut DbConn) -> JsonResult {
+// Return Status::Unauthorized to trigger logout
+async fn _refresh_login(data: ConnectData, conn: &mut DbConn, ip: &ClientIp) -> JsonResult {
     // Extract token
-    let token = data.refresh_token.unwrap();
-
-    // Get device by refresh token
-    let mut device = Device::find_by_refresh_token(&token, conn).await.map_res("Invalid refresh token")?;
-
-    let scope = "api offline_access";
-    let scope_vec = vec!["api".into(), "offline_access".into()];
+    let refresh_token = match data.refresh_token {
+        Some(token) => token,
+        None => err_code!("Missing refresh_token", Status::Unauthorized.code),
+    };
 
-    // Common
-    let user = User::find_by_uuid(&device.user_uuid, conn).await.unwrap();
     // ---
     // Disabled this variable, it was used to generate the JWT
     // Because this might get used in the future, and is add by the Bitwarden Server, lets keep it, but then commented out
     // See: https://github.com/dani-garcia/vaultwarden/issues/4156
     // ---
     // let members = Membership::find_confirmed_by_user(&user.uuid, conn).await;
-    let (access_token, expires_in) = device.refresh_tokens(&user, scope_vec, data.client_id);
-    device.save(conn).await?;
+    match auth::refresh_tokens(ip, &refresh_token, data.client_id, conn).await {
+        Err(err) => {
+            err_code!(format!("Unable to refresh login credentials: {}", err.message()), Status::Unauthorized.code)
+        }
+        Ok((mut device, auth_tokens)) => {
+            // Save to update `device.updated_at` to track usage and toggle new status
+            device.save(conn).await?;
+
+            let result = json!({
+                "refresh_token": auth_tokens.refresh_token(),
+                "access_token": auth_tokens.access_token(),
+                "expires_in": auth_tokens.expires_in(),
+                "token_type": "Bearer",
+                "scope": auth_tokens.scope(),
+            });
+
+            Ok(Json(result))
+        }
+    }
+}
 
-    let result = json!({
-        "access_token": access_token,
-        "expires_in": expires_in,
-        "token_type": "Bearer",
-        "refresh_token": device.refresh_token,
+// After exchanging the code we need to check first if 2FA is needed before continuing
+async fn _sso_login(
+    data: ConnectData,
+    user_id: &mut Option<UserId>,
+    conn: &mut DbConn,
+    ip: &ClientIp,
+    client_version: &Option<ClientVersion>,
+) -> JsonResult {
+    AuthMethod::Sso.check_scope(data.scope.as_ref())?;
 
-        "scope": scope,
-    });
+    // Ratelimit the login
+    crate::ratelimit::check_limit_login(&ip.ip)?;
 
-    Ok(Json(result))
+    let code = match data.code.as_ref() {
+        None => err!(
+            "Got no code in OIDC data",
+            ErrorEvent {
+                event: EventType::UserFailedLogIn
+            }
+        ),
+        Some(code) => code,
+    };
+
+    let user_infos = sso::exchange_code(code, conn).await?;
+    let user_with_sso = match SsoUser::find_by_identifier(&user_infos.identifier, conn).await {
+        None => match SsoUser::find_by_mail(&user_infos.email, conn).await {
+            None => None,
+            Some((user, Some(_))) => {
+                error!(
+                    "Login failure ({}), existing SSO user ({}) with same email ({})",
+                    user_infos.identifier, user.uuid, user.email
+                );
+                err_silent!(
+                    "Existing SSO user with same email",
+                    ErrorEvent {
+                        event: EventType::UserFailedLogIn
+                    }
+                )
+            }
+            Some((user, None)) if user.private_key.is_some() && !CONFIG.sso_signups_match_email() => {
+                error!(
+                    "Login failure ({}), existing non SSO user ({}) with same email ({}) and association is disabled",
+                    user_infos.identifier, user.uuid, user.email
+                );
+                err_silent!(
+                    "Existing non SSO user with same email",
+                    ErrorEvent {
+                        event: EventType::UserFailedLogIn
+                    }
+                )
+            }
+            Some((user, None)) => Some((user, None)),
+        },
+        Some((user, sso_user)) => Some((user, Some(sso_user))),
+    };
+
+    let now = Utc::now().naive_utc();
+    // Will trigger 2FA flow if needed
+    let (user, mut device, twofactor_token, sso_user) = match user_with_sso {
+        None => {
+            if !CONFIG.is_email_domain_allowed(&user_infos.email) {
+                err!(
+                    "Email domain not allowed",
+                    ErrorEvent {
+                        event: EventType::UserFailedLogIn
+                    }
+                );
+            }
+
+            match user_infos.email_verified {
+                None if !CONFIG.sso_allow_unknown_email_verification() => err!(
+                    "Your provider does not send email verification status.\n\
+                    You will need to change the server configuration (check `SSO_ALLOW_UNKNOWN_EMAIL_VERIFICATION`) to log in.",
+                    ErrorEvent {
+                        event: EventType::UserFailedLogIn
+                    }
+                ),
+                Some(false) => err!(
+                    "You need to verify your email with your provider before you can log in",
+                    ErrorEvent {
+                        event: EventType::UserFailedLogIn
+                    }
+                ),
+                _ => (),
+            }
+
+            let mut user = User::new(user_infos.email, user_infos.user_name);
+            user.verified_at = Some(now);
+            user.save(conn).await?;
+
+            let device = get_device(&data, conn, &user).await?;
+
+            (user, device, None, None)
+        }
+        Some((user, _)) if !user.enabled => {
+            err!(
+                "This user has been disabled",
+                format!("IP: {}. Username: {}.", ip.ip, user.name),
+                ErrorEvent {
+                    event: EventType::UserFailedLogIn
+                }
+            )
+        }
+        Some((mut user, sso_user)) => {
+            let mut device = get_device(&data, conn, &user).await?;
+            let twofactor_token = twofactor_auth(&user, &data, &mut device, ip, client_version, conn).await?;
+
+            if user.private_key.is_none() {
+                // User was invited a stub was created
+                user.verified_at = Some(now);
+                if let Some(user_name) = user_infos.user_name {
+                    user.name = user_name;
+                }
+
+                user.save(conn).await?;
+            }
+
+            if user.email != user_infos.email {
+                if CONFIG.mail_enabled() {
+                    mail::send_sso_change_email(&user_infos.email).await?;
+                }
+                info!("User {} email changed in SSO provider from {} to {}", user.uuid, user.email, user_infos.email);
+            }
+
+            (user, device, twofactor_token, sso_user)
+        }
+    };
+
+    // We passed 2FA get full user informations
+    let auth_user = sso::redeem(&user_infos.state, conn).await?;
+
+    if sso_user.is_none() {
+        let user_sso = SsoUser {
+            user_uuid: user.uuid.clone(),
+            identifier: user_infos.identifier,
+        };
+        user_sso.save(conn).await?;
+    }
+
+    // Set the user_uuid here to be passed back used for event logging.
+    *user_id = Some(user.uuid.clone());
+
+    let auth_tokens = sso::create_auth_tokens(
+        &device,
+        &user,
+        data.client_id,
+        auth_user.refresh_token,
+        auth_user.access_token,
+        auth_user.expires_in,
+    )?;
+
+    authenticated_response(&user, &mut device, auth_tokens, twofactor_token, &now, conn, ip).await
 }
 
 async fn _password_login(
@@ -141,11 +324,7 @@ async fn _password_login(
     client_version: &Option<ClientVersion>,
 ) -> JsonResult {
     // Validate scope
-    let scope = data.scope.as_ref().unwrap();
-    if scope != "api offline_access" {
-        err!("Scope not supported")
-    }
-    let scope_vec = vec!["api".into(), "offline_access".into()];
+    AuthMethod::Password.check_scope(data.scope.as_ref())?;
 
     // Ratelimit the login
     crate::ratelimit::check_limit_login(&ip.ip)?;
@@ -212,13 +391,8 @@ async fn _password_login(
     }
 
     // Change the KDF Iterations (only when not logging in with an auth request)
-    if data.auth_request.is_none() && user.password_iterations != CONFIG.password_iterations() {
-        user.password_iterations = CONFIG.password_iterations();
-        user.set_password(password, None, false, None);
-
-        if let Err(e) = user.save(conn).await {
-            error!("Error updating user: {e:#?}");
-        }
+    if data.auth_request.is_none() {
+        kdf_upgrade(&mut user, password, conn).await?;
     }
 
     let now = Utc::now().naive_utc();
@@ -255,12 +429,27 @@ async fn _password_login(
         )
     }
 
-    let (mut device, new_device) = get_device(&data, conn, &user).await;
+    let mut device = get_device(&data, conn, &user).await?;
 
     let twofactor_token = twofactor_auth(&user, &data, &mut device, ip, client_version, conn).await?;
 
-    if CONFIG.mail_enabled() && new_device {
-        if let Err(e) = mail::send_new_device_logged_in(&user.email, &ip.ip.to_string(), &now, &device).await {
+    let auth_tokens = auth::AuthTokens::new(&device, &user, AuthMethod::Password, data.client_id);
+
+    authenticated_response(&user, &mut device, auth_tokens, twofactor_token, &now, conn, ip).await
+}
+
+#[allow(clippy::too_many_arguments)]
+async fn authenticated_response(
+    user: &User,
+    device: &mut Device,
+    auth_tokens: auth::AuthTokens,
+    twofactor_token: Option<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() {
@@ -275,31 +464,21 @@ async fn _password_login(
     }
 
     // register push device
-    if !new_device {
-        register_push_device(&mut device, conn).await?;
+    if !device.is_new() {
+        register_push_device(device, conn).await?;
     }
 
-    // Common
-    // ---
-    // Disabled this variable, it was used to generate the JWT
-    // Because this might get used in the future, and is add by the Bitwarden Server, lets keep it, but then commented out
-    // See: https://github.com/dani-garcia/vaultwarden/issues/4156
-    // ---
-    // let members = Membership::find_confirmed_by_user(&user.uuid, conn).await;
-    let (access_token, expires_in) = device.refresh_tokens(&user, scope_vec, data.client_id);
+    // Save to update `device.updated_at` to track usage and toggle new status
     device.save(conn).await?;
 
-    let master_password_policy = master_password_policy(&user, conn).await;
+    let master_password_policy = master_password_policy(user, conn).await;
 
     let mut result = json!({
-        "access_token": access_token,
-        "expires_in": expires_in,
+        "access_token": auth_tokens.access_token(),
+        "expires_in": auth_tokens.expires_in(),
         "token_type": "Bearer",
-        "refresh_token": device.refresh_token,
-        "Key": user.akey,
+        "refresh_token": auth_tokens.refresh_token(),
         "PrivateKey": user.private_key,
-        //"TwoFactorToken": "11122233333444555666777888999"
-
         "Kdf": user.client_kdf_type,
         "KdfIterations": user.client_kdf_iter,
         "KdfMemory": user.client_kdf_memory,
@@ -307,19 +486,22 @@ async fn _password_login(
         "ResetMasterPassword": false, // TODO: Same as above
         "ForcePasswordReset": false,
         "MasterPasswordPolicy": master_password_policy,
-
-        "scope": scope,
+        "scope": auth_tokens.scope(),
         "UserDecryptionOptions": {
             "HasMasterPassword": !user.password_hash.is_empty(),
             "Object": "userDecryptionOptions"
         },
     });
 
+    if !user.akey.is_empty() {
+        result["Key"] = Value::String(user.akey.clone());
+    }
+
     if let Some(token) = twofactor_token {
         result["TwoFactorToken"] = Value::String(token);
     }
 
-    info!("User {username} logged in successfully. IP: {}", ip.ip);
+    info!("User {} logged in successfully. IP: {}", &user.name, ip.ip);
     Ok(Json(result))
 }
 
@@ -333,9 +515,9 @@ async fn _api_key_login(
     crate::ratelimit::check_limit_login(&ip.ip)?;
 
     // Validate scope
-    match data.scope.as_ref().unwrap().as_ref() {
-        "api" => _user_api_key_login(data, user_id, conn, ip).await,
-        "api.organization" => _organization_api_key_login(data, conn, ip).await,
+    match data.scope.as_ref() {
+        Some(scope) if scope == &AuthMethod::UserApiKey.scope() => _user_api_key_login(data, user_id, conn, ip).await,
+        Some(scope) if scope == &AuthMethod::OrgApiKey.scope() => _organization_api_key_login(data, conn, ip).await,
         _ => err!("Scope not supported"),
     }
 }
@@ -382,9 +564,9 @@ async fn _user_api_key_login(
         )
     }
 
-    let (mut device, new_device) = get_device(&data, conn, &user).await;
+    let mut device = get_device(&data, conn, &user).await?;
 
-    if CONFIG.mail_enabled() && new_device {
+    if CONFIG.mail_enabled() && device.is_new() {
         let now = Utc::now().naive_utc();
         if let Err(e) = mail::send_new_device_logged_in(&user.email, &ip.ip.to_string(), &now, &device).await {
             error!("Error sending new device email: {e:#?}");
@@ -400,15 +582,15 @@ async fn _user_api_key_login(
         }
     }
 
-    // Common
-    let scope_vec = vec!["api".into()];
     // ---
     // Disabled this variable, it was used to generate the JWT
     // Because this might get used in the future, and is add by the Bitwarden Server, lets keep it, but then commented out
     // See: https://github.com/dani-garcia/vaultwarden/issues/4156
     // ---
-    // let members = Membership::find_confirmed_by_user(&user.uuid, conn).await;
-    let (access_token, expires_in) = device.refresh_tokens(&user, scope_vec, data.client_id);
+    // let orgs = Membership::find_confirmed_by_user(&user.uuid, conn).await;
+    let access_claims = auth::LoginJwtClaims::default(&device, &user, &AuthMethod::UserApiKey, data.client_id);
+
+    // Save to update `device.updated_at` to track usage and toggle new status
     device.save(conn).await?;
 
     info!("User {} logged in successfully via API key. IP: {}", user.email, ip.ip);
@@ -416,8 +598,8 @@ async fn _user_api_key_login(
     // Note: No refresh_token is returned. The CLI just repeats the
     // client_credentials login flow when the existing token expires.
     let result = json!({
-        "access_token": access_token,
-        "expires_in": expires_in,
+        "access_token": access_claims.token(),
+        "expires_in": access_claims.expires_in(),
         "token_type": "Bearer",
         "Key": user.akey,
         "PrivateKey": user.private_key,
@@ -427,7 +609,7 @@ async fn _user_api_key_login(
         "KdfMemory": user.client_kdf_memory,
         "KdfParallelism": user.client_kdf_parallelism,
         "ResetMasterPassword": false, // TODO: according to official server seems something like: user.password_hash.is_empty(), but would need testing
-        "scope": "api",
+        "scope": AuthMethod::UserApiKey.scope(),
     });
 
     Ok(Json(result))
@@ -451,35 +633,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(
@@ -572,12 +748,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> {
@@ -727,9 +904,8 @@ async fn register_verification_email(
 
     let should_send_mail = CONFIG.mail_enabled() && CONFIG.signups_verify();
 
-    let token_claims =
-        crate::auth::generate_register_verify_claims(data.email.clone(), data.name.clone(), should_send_mail);
-    let token = crate::auth::encode_jwt(&token_claims);
+    let token_claims = auth::generate_register_verify_claims(data.email.clone(), data.name.clone(), should_send_mail);
+    let token = auth::encode_jwt(&token_claims);
 
     if should_send_mail {
         let user = User::find_by_mail(&data.email, &mut conn).await;
@@ -812,11 +988,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)))
+}

+ 4 - 1
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!({})
     };

+ 7 - 5
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) {

+ 246 - 8
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))
+}

+ 98 - 4
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");

+ 43 - 89
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(

+ 1 - 1
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,

+ 3 - 1
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};

+ 2 - 2
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,
         }
     }

+ 70 - 1
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 - 0
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")
+        }
+    }
+}

+ 88 - 5
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 - 0
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 - 0
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 - 0
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 - 0
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]

+ 17 - 1
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 - 0
src/main.rs

@@ -56,6 +56,8 @@ mod db;
 mod http_client;
 mod mail;
 mod ratelimit;
+mod sso;
+mod sso_client;
 mod util;
 
 use crate::api::core::two_factor::duo_oidc::purge_duo_contexts;
@@ -711,6 +713,13 @@ fn schedule_jobs(pool: db::DbPool) {
                 }));
             }
 
+            // Purge sso nonce from incomplete flow (default to daily at 00h20).
+            if !CONFIG.purge_incomplete_sso_nonce().is_empty() {
+                sched.add(Job::new(CONFIG.purge_incomplete_sso_nonce().parse().unwrap(), || {
+                    runtime.spawn(db::models::SsoNonce::delete_expired(pool.clone()));
+                }));
+            }
+
             // Periodically check for jobs to run. We probably won't need any
             // jobs that run more often than once a minute, so a default poll
             // interval of 30 seconds should be sufficient. Users who want to

+ 462 - 0
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"),
+    }
+}

+ 264 - 0
src/sso_client.rs

@@ -0,0 +1,264 @@
+use regex::Regex;
+use std::borrow::Cow;
+use std::time::Duration;
+use url::Url;
+
+use mini_moka::sync::Cache;
+use once_cell::sync::Lazy;
+use openidconnect::core::*;
+use openidconnect::reqwest;
+use openidconnect::*;
+
+use crate::{
+    api::{ApiResult, EmptyResult},
+    db::models::SsoNonce,
+    sso::{OIDCCode, OIDCState},
+    CONFIG,
+};
+
+static CLIENT_CACHE_KEY: Lazy<String> = Lazy::new(|| "sso-client".to_string());
+static CLIENT_CACHE: Lazy<Cache<String, Client>> = Lazy::new(|| {
+    Cache::builder().max_capacity(1).time_to_live(Duration::from_secs(CONFIG.sso_client_cache_expiration())).build()
+});
+
+/// OpenID Connect Core client.
+pub type CustomClient = openidconnect::Client<
+    EmptyAdditionalClaims,
+    CoreAuthDisplay,
+    CoreGenderClaim,
+    CoreJweContentEncryptionAlgorithm,
+    CoreJsonWebKey,
+    CoreAuthPrompt,
+    StandardErrorResponse<CoreErrorResponseType>,
+    CoreTokenResponse,
+    CoreTokenIntrospectionResponse,
+    CoreRevocableToken,
+    CoreRevocationErrorResponse,
+    EndpointSet,
+    EndpointNotSet,
+    EndpointNotSet,
+    EndpointNotSet,
+    EndpointSet,
+    EndpointSet,
+>;
+
+#[derive(Clone)]
+pub struct Client {
+    pub http_client: reqwest::Client,
+    pub core_client: CustomClient,
+}
+
+impl Client {
+    // Call the OpenId discovery endpoint to retrieve configuration
+    async fn _get_client() -> ApiResult<Self> {
+        let client_id = ClientId::new(CONFIG.sso_client_id());
+        let client_secret = ClientSecret::new(CONFIG.sso_client_secret());
+
+        let issuer_url = CONFIG.sso_issuer_url()?;
+
+        let http_client = match reqwest::ClientBuilder::new().redirect(reqwest::redirect::Policy::none()).build() {
+            Err(err) => err!(format!("Failed to build http client: {err}")),
+            Ok(client) => client,
+        };
+
+        let provider_metadata = match CoreProviderMetadata::discover_async(issuer_url, &http_client).await {
+            Err(err) => err!(format!("Failed to discover OpenID provider: {err}")),
+            Ok(metadata) => metadata,
+        };
+
+        let base_client = CoreClient::from_provider_metadata(provider_metadata, client_id, Some(client_secret));
+
+        let token_uri = match base_client.token_uri() {
+            Some(uri) => uri.clone(),
+            None => err!("Failed to discover token_url, cannot proceed"),
+        };
+
+        let user_info_url = match base_client.user_info_url() {
+            Some(url) => url.clone(),
+            None => err!("Failed to discover user_info url, cannot proceed"),
+        };
+
+        let core_client = base_client
+            .set_redirect_uri(CONFIG.sso_redirect_url()?)
+            .set_token_uri(token_uri)
+            .set_user_info_url(user_info_url);
+
+        Ok(Client {
+            http_client,
+            core_client,
+        })
+    }
+
+    // Simple cache to prevent recalling the discovery endpoint each time
+    pub async fn cached() -> ApiResult<Self> {
+        if CONFIG.sso_client_cache_expiration() > 0 {
+            match CLIENT_CACHE.get(&*CLIENT_CACHE_KEY) {
+                Some(client) => Ok(client),
+                None => Self::_get_client().await.inspect(|client| {
+                    debug!("Inserting new client in cache");
+                    CLIENT_CACHE.insert(CLIENT_CACHE_KEY.clone(), client.clone());
+                }),
+            }
+        } else {
+            Self::_get_client().await
+        }
+    }
+
+    pub fn invalidate() {
+        if CONFIG.sso_client_cache_expiration() > 0 {
+            CLIENT_CACHE.invalidate(&*CLIENT_CACHE_KEY);
+        }
+    }
+
+    // The `state` is encoded using base64 to ensure no issue with providers (It contains the Organization identifier).
+    pub async fn authorize_url(state: OIDCState, redirect_uri: String) -> ApiResult<(Url, SsoNonce)> {
+        let scopes = CONFIG.sso_scopes_vec().into_iter().map(Scope::new);
+        let base64_state = data_encoding::BASE64.encode(state.to_string().as_bytes());
+
+        let client = Self::cached().await?;
+        let mut auth_req = client
+            .core_client
+            .authorize_url(
+                AuthenticationFlow::<CoreResponseType>::AuthorizationCode,
+                || CsrfToken::new(base64_state),
+                Nonce::new_random,
+            )
+            .add_scopes(scopes)
+            .add_extra_params(CONFIG.sso_authorize_extra_params_vec());
+
+        let verifier = if CONFIG.sso_pkce() {
+            let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
+            auth_req = auth_req.set_pkce_challenge(pkce_challenge);
+            Some(pkce_verifier.into_secret())
+        } else {
+            None
+        };
+
+        let (auth_url, _, nonce) = auth_req.url();
+        Ok((auth_url, SsoNonce::new(state, nonce.secret().clone(), verifier, redirect_uri)))
+    }
+
+    pub async fn exchange_code(
+        &self,
+        code: OIDCCode,
+        nonce: SsoNonce,
+    ) -> ApiResult<(
+        StandardTokenResponse<
+            IdTokenFields<
+                EmptyAdditionalClaims,
+                EmptyExtraTokenFields,
+                CoreGenderClaim,
+                CoreJweContentEncryptionAlgorithm,
+                CoreJwsSigningAlgorithm,
+            >,
+            CoreTokenType,
+        >,
+        IdTokenClaims<EmptyAdditionalClaims, CoreGenderClaim>,
+    )> {
+        let oidc_code = AuthorizationCode::new(code.to_string());
+
+        let mut exchange = self.core_client.exchange_code(oidc_code);
+
+        if CONFIG.sso_pkce() {
+            match nonce.verifier {
+                None => err!(format!("Missing verifier in the DB nonce table")),
+                Some(secret) => exchange = exchange.set_pkce_verifier(PkceCodeVerifier::new(secret.clone())),
+            }
+        }
+
+        match exchange.request_async(&self.http_client).await {
+            Err(err) => err!(format!("Failed to contact token endpoint: {:?}", err)),
+            Ok(token_response) => {
+                let oidc_nonce = Nonce::new(nonce.nonce);
+
+                let id_token = match token_response.extra_fields().id_token() {
+                    None => err!("Token response did not contain an id_token"),
+                    Some(token) => token,
+                };
+
+                if CONFIG.sso_debug_tokens() {
+                    debug!("Id token: {}", id_token.to_string());
+                    debug!("Access token: {}", token_response.access_token().secret());
+                    debug!("Refresh token: {:?}", token_response.refresh_token().map(|t| t.secret()));
+                    debug!("Expiration time: {:?}", token_response.expires_in());
+                }
+
+                let id_claims = match id_token.claims(&self.vw_id_token_verifier(), &oidc_nonce) {
+                    Ok(claims) => claims.clone(),
+                    Err(err) => {
+                        Self::invalidate();
+                        err!(format!("Could not read id_token claims, {err}"));
+                    }
+                };
+
+                Ok((token_response, id_claims))
+            }
+        }
+    }
+
+    pub async fn user_info(&self, access_token: AccessToken) -> ApiResult<CoreUserInfoClaims> {
+        match self.core_client.user_info(access_token, None).request_async(&self.http_client).await {
+            Err(err) => err!(format!("Request to user_info endpoint failed: {err}")),
+            Ok(user_info) => Ok(user_info),
+        }
+    }
+
+    pub async fn check_validaty(access_token: String) -> EmptyResult {
+        let client = Client::cached().await?;
+        match client.user_info(AccessToken::new(access_token)).await {
+            Err(err) => {
+                err_silent!(format!("Failed to retrieve user info, token has probably been invalidated: {err}"))
+            }
+            Ok(_) => Ok(()),
+        }
+    }
+
+    pub fn vw_id_token_verifier(&self) -> CoreIdTokenVerifier<'_> {
+        let mut verifier = self.core_client.id_token_verifier();
+        if let Some(regex_str) = CONFIG.sso_audience_trusted() {
+            match Regex::new(&regex_str) {
+                Ok(regex) => {
+                    verifier = verifier.set_other_audience_verifier_fn(move |aud| regex.is_match(aud));
+                }
+                Err(err) => {
+                    error!("Failed to parse SSO_AUDIENCE_TRUSTED={regex_str} regex: {err}");
+                }
+            }
+        }
+        verifier
+    }
+
+    pub async fn exchange_refresh_token(
+        refresh_token: String,
+    ) -> ApiResult<(Option<String>, String, Option<Duration>)> {
+        let rt = RefreshToken::new(refresh_token);
+
+        let client = Client::cached().await?;
+        let token_response =
+            match client.core_client.exchange_refresh_token(&rt).request_async(&client.http_client).await {
+                Err(err) => err!(format!("Request to exchange_refresh_token endpoint failed: {:?}", err)),
+                Ok(token_response) => token_response,
+            };
+
+        Ok((
+            token_response.refresh_token().map(|token| token.secret().clone()),
+            token_response.access_token().secret().clone(),
+            token_response.expires_in(),
+        ))
+    }
+}
+
+trait AuthorizationRequestExt<'a> {
+    fn add_extra_params<N: Into<Cow<'a, str>>, V: Into<Cow<'a, str>>>(self, params: Vec<(N, V)>) -> Self;
+}
+
+impl<'a, AD: AuthDisplay, P: AuthPrompt, RT: ResponseType> AuthorizationRequestExt<'a>
+    for AuthorizationRequest<'a, AD, P, RT>
+{
+    fn add_extra_params<N: Into<Cow<'a, str>>, V: Into<Cow<'a, str>>>(mut self, params: Vec<(N, V)>) -> Self {
+        for (key, value) in params {
+            self = self.add_extra_param(key, value);
+        }
+        self
+    }
+}

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio