mirror of
				https://github.com/dani-garcia/vaultwarden.git
				synced 2025-10-30 05:11:15 +00:00 
			
		
		
		
	SSO using OpenID Connect (#3899)
* Add SSO functionality using OpenID Connect Co-authored-by: Pablo Ovelleiro Corral <mail@pablo.tools> Co-authored-by: Stuart Heap <sheap13@gmail.com> Co-authored-by: Alex Moore <skiepp@my-dockerfarm.cloud> Co-authored-by: Brian Munro <brian.alexander.munro@gmail.com> Co-authored-by: Jacques B. <timshel@github.com> * 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 <felix@eckhofer.com> Co-authored-by: Pablo Ovelleiro Corral <mail@pablo.tools> Co-authored-by: Stuart Heap <sheap13@gmail.com> Co-authored-by: Alex Moore <skiepp@my-dockerfarm.cloud> Co-authored-by: Brian Munro <brian.alexander.munro@gmail.com> Co-authored-by: Jacques B. <timshel@github.com> Co-authored-by: Timshel <timshel@480s>
This commit is contained in:
		
					parent
					
						
							
								a0c76284fd
							
						
					
				
			
			
				commit
				
					
						cff6c2b3af
					
				
			
		
					 110 changed files with 8081 additions and 329 deletions
				
			
		|  | @ -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. | ## 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. | ## Defaults to every minute. Set blank to disable this job. | ||||||
| # DUO_CONTEXT_PURGE_SCHEDULE="30 * * * * *" | # 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 ### | ### 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. | ## 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 | # 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 ### | ### MFA/2FA settings ### | ||||||
| ######################## | ######################## | ||||||
|  |  | ||||||
							
								
								
									
										572
									
								
								Cargo.lock
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										572
									
								
								Cargo.lock
									
										
									
										generated
									
									
									
								
							|  | @ -655,6 +655,12 @@ dependencies = [ | ||||||
|  "windows-targets 0.52.6", |  "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]] | [[package]] | ||||||
| name = "base64" | name = "base64" | ||||||
| version = "0.13.1" | version = "0.13.1" | ||||||
|  | @ -781,6 +787,12 @@ version = "3.19.0" | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" | checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" | ||||||
| 
 | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "bytecount" | ||||||
|  | version = "0.6.9" | ||||||
|  | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
|  | checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" | ||||||
|  | 
 | ||||||
| [[package]] | [[package]] | ||||||
| name = "bytemuck" | name = "bytemuck" | ||||||
| version = "1.23.1" | version = "1.23.1" | ||||||
|  | @ -845,6 +857,37 @@ version = "0.1.1" | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0" | 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]] | [[package]] | ||||||
| name = "cbc" | name = "cbc" | ||||||
| version = "0.1.2" | version = "0.1.2" | ||||||
|  | @ -1092,6 +1135,18 @@ version = "0.2.4" | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" | 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]] | [[package]] | ||||||
| name = "crypto-common" | name = "crypto-common" | ||||||
| version = "0.1.6" | version = "0.1.6" | ||||||
|  | @ -1102,6 +1157,33 @@ dependencies = [ | ||||||
|  "typenum", |  "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]] | [[package]] | ||||||
| name = "darling" | name = "darling" | ||||||
| version = "0.20.11" | version = "0.20.11" | ||||||
|  | @ -1137,6 +1219,19 @@ dependencies = [ | ||||||
|  "syn", |  "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]] | [[package]] | ||||||
| name = "dashmap" | name = "dashmap" | ||||||
| version = "6.1.0" | version = "6.1.0" | ||||||
|  | @ -1181,6 +1276,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" | checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" | ||||||
| dependencies = [ | dependencies = [ | ||||||
|  "powerfmt", |  "powerfmt", | ||||||
|  |  "serde", | ||||||
| ] | ] | ||||||
| 
 | 
 | ||||||
| [[package]] | [[package]] | ||||||
|  | @ -1407,12 +1503,77 @@ dependencies = [ | ||||||
|  "syn", |  "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]] | [[package]] | ||||||
| name = "either" | name = "either" | ||||||
| version = "1.15.0" | version = "1.15.0" | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" | 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]] | [[package]] | ||||||
| name = "email-encoding" | name = "email-encoding" | ||||||
| version = "0.4.1" | version = "0.4.1" | ||||||
|  | @ -1475,6 +1636,15 @@ dependencies = [ | ||||||
|  "windows-sys 0.60.2", |  "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]] | [[package]] | ||||||
| name = "event-listener" | name = "event-listener" | ||||||
| version = "2.5.3" | version = "2.5.3" | ||||||
|  | @ -1520,6 +1690,22 @@ dependencies = [ | ||||||
|  "syslog", |  "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]] | [[package]] | ||||||
| name = "figment" | name = "figment" | ||||||
| version = "0.10.19" | version = "0.10.19" | ||||||
|  | @ -1723,6 +1909,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" | ||||||
| dependencies = [ | dependencies = [ | ||||||
|  "typenum", |  "typenum", | ||||||
|  "version_check", |  "version_check", | ||||||
|  |  "zeroize", | ||||||
| ] | ] | ||||||
| 
 | 
 | ||||||
| [[package]] | [[package]] | ||||||
|  | @ -1783,7 +1970,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "3cbe789d04bf14543f03c4b60cd494148aa79438c8440ae7d81a7778147745c3" | checksum = "3cbe789d04bf14543f03c4b60cd494148aa79438c8440ae7d81a7778147745c3" | ||||||
| dependencies = [ | dependencies = [ | ||||||
|  "cfg-if", |  "cfg-if", | ||||||
|  "dashmap", |  "dashmap 6.1.0", | ||||||
|  "futures-sink", |  "futures-sink", | ||||||
|  "futures-timer", |  "futures-timer", | ||||||
|  "futures-util", |  "futures-util", | ||||||
|  | @ -1806,12 +1993,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "2d9e3df7f0222ce5184154973d247c591d9aadc28ce7a73c6cd31100c9facff6" | checksum = "2d9e3df7f0222ce5184154973d247c591d9aadc28ce7a73c6cd31100c9facff6" | ||||||
| dependencies = [ | dependencies = [ | ||||||
|  "codemap", |  "codemap", | ||||||
|  "indexmap", |  "indexmap 2.10.0", | ||||||
|  "lasso", |  "lasso", | ||||||
|  "once_cell", |  "once_cell", | ||||||
|  "phf 0.11.3", |  "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]] | [[package]] | ||||||
| name = "h2" | name = "h2" | ||||||
| version = "0.4.11" | version = "0.4.11" | ||||||
|  | @ -1824,7 +2022,7 @@ dependencies = [ | ||||||
|  "futures-core", |  "futures-core", | ||||||
|  "futures-sink", |  "futures-sink", | ||||||
|  "http 1.3.1", |  "http 1.3.1", | ||||||
|  "indexmap", |  "indexmap 2.10.0", | ||||||
|  "slab", |  "slab", | ||||||
|  "tokio", |  "tokio", | ||||||
|  "tokio-util", |  "tokio-util", | ||||||
|  | @ -1854,6 +2052,12 @@ dependencies = [ | ||||||
|  "walkdir", |  "walkdir", | ||||||
| ] | ] | ||||||
| 
 | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "hashbrown" | ||||||
|  | version = "0.12.3" | ||||||
|  | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
|  | checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" | ||||||
|  | 
 | ||||||
| [[package]] | [[package]] | ||||||
| name = "hashbrown" | name = "hashbrown" | ||||||
| version = "0.14.5" | version = "0.14.5" | ||||||
|  | @ -1939,6 +2143,15 @@ dependencies = [ | ||||||
|  "tracing", |  "tracing", | ||||||
| ] | ] | ||||||
| 
 | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "hkdf" | ||||||
|  | version = "0.12.4" | ||||||
|  | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
|  | checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" | ||||||
|  | dependencies = [ | ||||||
|  |  "hmac", | ||||||
|  | ] | ||||||
|  | 
 | ||||||
| [[package]] | [[package]] | ||||||
| name = "hmac" | name = "hmac" | ||||||
| version = "0.12.1" | version = "0.12.1" | ||||||
|  | @ -2106,6 +2319,22 @@ dependencies = [ | ||||||
|  "webpki-roots", |  "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]] | [[package]] | ||||||
| name = "hyper-util" | name = "hyper-util" | ||||||
| version = "0.1.16" | version = "0.1.16" | ||||||
|  | @ -2269,6 +2498,17 @@ dependencies = [ | ||||||
|  "icu_properties", |  "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]] | [[package]] | ||||||
| name = "indexmap" | name = "indexmap" | ||||||
| version = "2.10.0" | version = "2.10.0" | ||||||
|  | @ -2346,6 +2586,15 @@ dependencies = [ | ||||||
|  "windows-sys 0.59.0", |  "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]] | [[package]] | ||||||
| name = "itoa" | name = "itoa" | ||||||
| version = "1.0.15" | version = "1.0.15" | ||||||
|  | @ -2636,6 +2885,21 @@ version = "0.3.17" | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" | 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]] | [[package]] | ||||||
| name = "minimal-lexical" | name = "minimal-lexical" | ||||||
| version = "0.2.1" | version = "0.2.1" | ||||||
|  | @ -2711,6 +2975,23 @@ dependencies = [ | ||||||
|  "vcpkg", |  "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]] | [[package]] | ||||||
| name = "nom" | name = "nom" | ||||||
| version = "7.1.3" | version = "7.1.3" | ||||||
|  | @ -2854,6 +3135,26 @@ dependencies = [ | ||||||
|  "libc", |  "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]] | [[package]] | ||||||
| name = "object" | name = "object" | ||||||
| version = "0.36.7" | version = "0.36.7" | ||||||
|  | @ -2901,6 +3202,37 @@ dependencies = [ | ||||||
|  "uuid", |  "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]] | [[package]] | ||||||
| name = "openssl" | name = "openssl" | ||||||
| version = "0.10.73" | version = "0.10.73" | ||||||
|  | @ -2955,6 +3287,15 @@ dependencies = [ | ||||||
|  "vcpkg", |  "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]] | [[package]] | ||||||
| name = "ordered-multimap" | name = "ordered-multimap" | ||||||
| version = "0.7.3" | version = "0.7.3" | ||||||
|  | @ -2977,6 +3318,30 @@ version = "0.1.1" | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" | 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]] | [[package]] | ||||||
| name = "parking" | name = "parking" | ||||||
| version = "2.2.1" | version = "2.2.1" | ||||||
|  | @ -3318,6 +3683,15 @@ dependencies = [ | ||||||
|  "vcpkg", |  "vcpkg", | ||||||
| ] | ] | ||||||
| 
 | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "primeorder" | ||||||
|  | version = "0.13.6" | ||||||
|  | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
|  | checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" | ||||||
|  | dependencies = [ | ||||||
|  |  "elliptic-curve", | ||||||
|  | ] | ||||||
|  | 
 | ||||||
| [[package]] | [[package]] | ||||||
| name = "proc-macro2" | name = "proc-macro2" | ||||||
| version = "1.0.95" | version = "1.0.95" | ||||||
|  | @ -3365,6 +3739,17 @@ dependencies = [ | ||||||
|  "psl-types", |  "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]] | [[package]] | ||||||
| name = "quanta" | name = "quanta" | ||||||
| version = "0.12.6" | version = "0.12.6" | ||||||
|  | @ -3695,10 +4080,12 @@ dependencies = [ | ||||||
|  "http-body-util", |  "http-body-util", | ||||||
|  "hyper 1.6.0", |  "hyper 1.6.0", | ||||||
|  "hyper-rustls", |  "hyper-rustls", | ||||||
|  |  "hyper-tls", | ||||||
|  "hyper-util", |  "hyper-util", | ||||||
|  "js-sys", |  "js-sys", | ||||||
|  "log", |  "log", | ||||||
|  "mime", |  "mime", | ||||||
|  |  "native-tls", | ||||||
|  "percent-encoding", |  "percent-encoding", | ||||||
|  "pin-project-lite", |  "pin-project-lite", | ||||||
|  "quinn", |  "quinn", | ||||||
|  | @ -3710,6 +4097,7 @@ dependencies = [ | ||||||
|  "serde_urlencoded", |  "serde_urlencoded", | ||||||
|  "sync_wrapper", |  "sync_wrapper", | ||||||
|  "tokio", |  "tokio", | ||||||
|  |  "tokio-native-tls", | ||||||
|  "tokio-rustls 0.26.2", |  "tokio-rustls 0.26.2", | ||||||
|  "tokio-util", |  "tokio-util", | ||||||
|  "tower", |  "tower", | ||||||
|  | @ -3729,6 +4117,16 @@ version = "0.7.4" | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "95325155c684b1c89f7765e30bc1c42e4a6da51ca513615660cb8a62ef9a88e3" | 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]] | [[package]] | ||||||
| name = "ring" | name = "ring" | ||||||
| version = "0.17.14" | version = "0.17.14" | ||||||
|  | @ -3778,7 +4176,7 @@ dependencies = [ | ||||||
|  "either", |  "either", | ||||||
|  "figment", |  "figment", | ||||||
|  "futures", |  "futures", | ||||||
|  "indexmap", |  "indexmap 2.10.0", | ||||||
|  "log", |  "log", | ||||||
|  "memchr", |  "memchr", | ||||||
|  "multer", |  "multer", | ||||||
|  | @ -3810,7 +4208,7 @@ checksum = "575d32d7ec1a9770108c879fc7c47815a80073f96ca07ff9525a94fcede1dd46" | ||||||
| dependencies = [ | dependencies = [ | ||||||
|  "devise", |  "devise", | ||||||
|  "glob", |  "glob", | ||||||
|  "indexmap", |  "indexmap 2.10.0", | ||||||
|  "proc-macro2", |  "proc-macro2", | ||||||
|  "quote", |  "quote", | ||||||
|  "rocket_http", |  "rocket_http", | ||||||
|  | @ -3830,7 +4228,7 @@ dependencies = [ | ||||||
|  "futures", |  "futures", | ||||||
|  "http 0.2.12", |  "http 0.2.12", | ||||||
|  "hyper 0.14.32", |  "hyper 0.14.32", | ||||||
|  "indexmap", |  "indexmap 2.10.0", | ||||||
|  "log", |  "log", | ||||||
|  "memchr", |  "memchr", | ||||||
|  "pear", |  "pear", | ||||||
|  | @ -3981,7 +4379,7 @@ dependencies = [ | ||||||
|  "openssl-probe", |  "openssl-probe", | ||||||
|  "rustls-pki-types", |  "rustls-pki-types", | ||||||
|  "schannel", |  "schannel", | ||||||
|  "security-framework", |  "security-framework 3.2.0", | ||||||
| ] | ] | ||||||
| 
 | 
 | ||||||
| [[package]] | [[package]] | ||||||
|  | @ -4072,6 +4470,30 @@ dependencies = [ | ||||||
|  "parking_lot", |  "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]] | [[package]] | ||||||
| name = "scoped-tls" | name = "scoped-tls" | ||||||
| version = "1.0.1" | version = "1.0.1" | ||||||
|  | @ -4105,6 +4527,33 @@ dependencies = [ | ||||||
|  "untrusted", |  "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]] | [[package]] | ||||||
| name = "security-framework" | name = "security-framework" | ||||||
| version = "3.2.0" | version = "3.2.0" | ||||||
|  | @ -4133,6 +4582,9 @@ name = "semver" | ||||||
| version = "1.0.26" | version = "1.0.26" | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" | checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" | ||||||
|  | dependencies = [ | ||||||
|  |  "serde", | ||||||
|  | ] | ||||||
| 
 | 
 | ||||||
| [[package]] | [[package]] | ||||||
| name = "serde" | name = "serde" | ||||||
|  | @ -4143,6 +4595,16 @@ dependencies = [ | ||||||
|  "serde_derive", |  "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]] | [[package]] | ||||||
| name = "serde_cbor" | name = "serde_cbor" | ||||||
| version = "0.11.2" | version = "0.11.2" | ||||||
|  | @ -4176,6 +4638,25 @@ dependencies = [ | ||||||
|  "serde", |  "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]] | [[package]] | ||||||
| name = "serde_spanned" | name = "serde_spanned" | ||||||
| version = "0.6.9" | version = "0.6.9" | ||||||
|  | @ -4206,6 +4687,38 @@ dependencies = [ | ||||||
|  "serde", |  "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]] | [[package]] | ||||||
| name = "sha1" | name = "sha1" | ||||||
| version = "0.10.6" | version = "0.10.6" | ||||||
|  | @ -4290,6 +4803,21 @@ version = "1.0.1" | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" | 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]] | [[package]] | ||||||
| name = "slab" | name = "slab" | ||||||
| version = "0.4.10" | version = "0.4.10" | ||||||
|  | @ -4649,6 +5177,16 @@ dependencies = [ | ||||||
|  "syn", |  "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]] | [[package]] | ||||||
| name = "tokio-rustls" | name = "tokio-rustls" | ||||||
| version = "0.24.1" | version = "0.24.1" | ||||||
|  | @ -4724,7 +5262,7 @@ version = "0.9.2" | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "ed0aee96c12fa71097902e0bb061a5e1ebd766a6636bb605ba401c45c1650eac" | checksum = "ed0aee96c12fa71097902e0bb061a5e1ebd766a6636bb605ba401c45c1650eac" | ||||||
| dependencies = [ | dependencies = [ | ||||||
|  "indexmap", |  "indexmap 2.10.0", | ||||||
|  "serde", |  "serde", | ||||||
|  "serde_spanned 1.0.0", |  "serde_spanned 1.0.0", | ||||||
|  "toml_datetime 0.7.0", |  "toml_datetime 0.7.0", | ||||||
|  | @ -4757,7 +5295,7 @@ version = "0.22.27" | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" | checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" | ||||||
| dependencies = [ | dependencies = [ | ||||||
|  "indexmap", |  "indexmap 2.10.0", | ||||||
|  "serde", |  "serde", | ||||||
|  "serde_spanned 0.6.9", |  "serde_spanned 0.6.9", | ||||||
|  "toml_datetime 0.6.11", |  "toml_datetime 0.6.11", | ||||||
|  | @ -4905,6 +5443,12 @@ dependencies = [ | ||||||
|  "tracing-log", |  "tracing-log", | ||||||
| ] | ] | ||||||
| 
 | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "triomphe" | ||||||
|  | version = "0.1.14" | ||||||
|  | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
|  | checksum = "ef8f7726da4807b58ea5c96fdc122f80702030edc33b35aff9190a51148ccc85" | ||||||
|  | 
 | ||||||
| [[package]] | [[package]] | ||||||
| name = "try-lock" | name = "try-lock" | ||||||
| version = "0.2.5" | version = "0.2.5" | ||||||
|  | @ -4961,6 +5505,12 @@ dependencies = [ | ||||||
|  "version_check", |  "version_check", | ||||||
| ] | ] | ||||||
| 
 | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "unicase" | ||||||
|  | version = "2.8.1" | ||||||
|  | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
|  | checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" | ||||||
|  | 
 | ||||||
| [[package]] | [[package]] | ||||||
| name = "unicode-ident" | name = "unicode-ident" | ||||||
| version = "1.0.18" | version = "1.0.18" | ||||||
|  | @ -5049,7 +5599,7 @@ dependencies = [ | ||||||
|  "chrono-tz", |  "chrono-tz", | ||||||
|  "cookie", |  "cookie", | ||||||
|  "cookie_store", |  "cookie_store", | ||||||
|  "dashmap", |  "dashmap 6.1.0", | ||||||
|  "data-encoding", |  "data-encoding", | ||||||
|  "data-url", |  "data-url", | ||||||
|  "derive_more", |  "derive_more", | ||||||
|  | @ -5074,10 +5624,12 @@ dependencies = [ | ||||||
|  "log", |  "log", | ||||||
|  "macros", |  "macros", | ||||||
|  "mimalloc", |  "mimalloc", | ||||||
|  |  "mini-moka", | ||||||
|  "num-derive", |  "num-derive", | ||||||
|  "num-traits", |  "num-traits", | ||||||
|  "once_cell", |  "once_cell", | ||||||
|  "opendal", |  "opendal", | ||||||
|  |  "openidconnect", | ||||||
|  "openssl", |  "openssl", | ||||||
|  "pastey", |  "pastey", | ||||||
|  "percent-encoding", |  "percent-encoding", | ||||||
|  |  | ||||||
|  | @ -34,6 +34,10 @@ enable_mimalloc = ["dep:mimalloc"] | ||||||
| query_logger = ["dep:diesel_logger"] | 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"] | 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 | # Enable unstable features, requires nightly | ||||||
| # Currently only used to enable rusts official ip support | # Currently only used to enable rusts official ip support | ||||||
| unstable = [] | unstable = [] | ||||||
|  | @ -161,6 +165,10 @@ pico-args = "0.5.0" | ||||||
| pastey = "0.1.0" | pastey = "0.1.0" | ||||||
| governor = "0.10.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. | # Check client versions for specific features. | ||||||
| semver = "1.0.26" | semver = "1.0.26" | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
							
								
								
									
										303
									
								
								SSO.md
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										303
									
								
								SSO.md
									
										
									
									
									
										Normal file
									
								
							|  | @ -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
									
								
								migrations/mysql/2023-09-10-133000_add_sso/down.sql
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								migrations/mysql/2023-09-10-133000_add_sso/down.sql
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1 @@ | ||||||
|  | DROP TABLE sso_nonce; | ||||||
							
								
								
									
										4
									
								
								migrations/mysql/2023-09-10-133000_add_sso/up.sql
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								migrations/mysql/2023-09-10-133000_add_sso/up.sql
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,4 @@ | ||||||
|  | CREATE TABLE sso_nonce ( | ||||||
|  |   nonce               CHAR(36) NOT NULL PRIMARY KEY, | ||||||
|  |   created_at          DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | ||||||
|  | ); | ||||||
|  | @ -0,0 +1 @@ | ||||||
|  | ALTER TABLE users_organizations DROP COLUMN invited_by_email; | ||||||
|  | @ -0,0 +1 @@ | ||||||
|  | ALTER TABLE users_organizations ADD COLUMN invited_by_email TEXT DEFAULT NULL; | ||||||
|  | @ -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 | ||||||
|  | ); | ||||||
|  | @ -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() | ||||||
|  | ); | ||||||
|  | @ -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() | ||||||
|  | ); | ||||||
|  | @ -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() | ||||||
|  | ); | ||||||
|  | @ -0,0 +1 @@ | ||||||
|  | DROP TABLE IF EXISTS sso_users; | ||||||
							
								
								
									
										7
									
								
								migrations/mysql/2024-03-06-170000_add_sso_users/up.sql
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								migrations/mysql/2024-03-06-170000_add_sso_users/up.sql
									
										
									
									
									
										Normal file
									
								
							|  | @ -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 +1,2 @@ | ||||||
|  | ALTER TABLE sso_users DROP FOREIGN KEY `sso_users_ibfk_1`; | ||||||
|  | ALTER TABLE sso_users ADD FOREIGN KEY(user_uuid) REFERENCES users(uuid) ON UPDATE CASCADE ON DELETE CASCADE; | ||||||
							
								
								
									
										1
									
								
								migrations/postgresql/2023-09-10-133000_add_sso/down.sql
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								migrations/postgresql/2023-09-10-133000_add_sso/down.sql
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1 @@ | ||||||
|  | DROP TABLE sso_nonce; | ||||||
							
								
								
									
										4
									
								
								migrations/postgresql/2023-09-10-133000_add_sso/up.sql
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								migrations/postgresql/2023-09-10-133000_add_sso/up.sql
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,4 @@ | ||||||
|  | CREATE TABLE sso_nonce ( | ||||||
|  |   nonce               CHAR(36) NOT NULL PRIMARY KEY, | ||||||
|  |   created_at          TIMESTAMP NOT NULL DEFAULT now() | ||||||
|  | ); | ||||||
|  | @ -0,0 +1 @@ | ||||||
|  | ALTER TABLE users_organizations DROP COLUMN invited_by_email; | ||||||
|  | @ -0,0 +1 @@ | ||||||
|  | ALTER TABLE users_organizations ADD COLUMN invited_by_email TEXT DEFAULT NULL; | ||||||
|  | @ -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() | ||||||
|  | ); | ||||||
|  | @ -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() | ||||||
|  | ); | ||||||
|  | @ -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() | ||||||
|  | ); | ||||||
|  | @ -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() | ||||||
|  | ); | ||||||
|  | @ -0,0 +1 @@ | ||||||
|  | DROP TABLE IF EXISTS sso_users; | ||||||
|  | @ -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 +1,3 @@ | ||||||
|  | ALTER TABLE sso_users | ||||||
|  |   DROP CONSTRAINT "sso_users_user_uuid_fkey", | ||||||
|  |   ADD CONSTRAINT "sso_users_user_uuid_fkey" FOREIGN KEY(user_uuid) REFERENCES users(uuid) ON UPDATE CASCADE ON DELETE CASCADE; | ||||||
							
								
								
									
										1
									
								
								migrations/sqlite/2023-09-10-133000_add_sso/down.sql
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								migrations/sqlite/2023-09-10-133000_add_sso/down.sql
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1 @@ | ||||||
|  | DROP TABLE sso_nonce; | ||||||
							
								
								
									
										4
									
								
								migrations/sqlite/2023-09-10-133000_add_sso/up.sql
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								migrations/sqlite/2023-09-10-133000_add_sso/up.sql
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,4 @@ | ||||||
|  | CREATE TABLE sso_nonce ( | ||||||
|  |   nonce               CHAR(36) NOT NULL PRIMARY KEY, | ||||||
|  |   created_at          DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | ||||||
|  | ); | ||||||
|  | @ -0,0 +1 @@ | ||||||
|  | ALTER TABLE users_organizations DROP COLUMN invited_by_email; | ||||||
|  | @ -0,0 +1 @@ | ||||||
|  | ALTER TABLE users_organizations ADD COLUMN invited_by_email TEXT DEFAULT NULL; | ||||||
|  | @ -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 | ||||||
|  | ); | ||||||
|  | @ -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 | ||||||
|  | ); | ||||||
|  | @ -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 | ||||||
|  | ); | ||||||
|  | @ -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 | ||||||
|  | ); | ||||||
|  | @ -0,0 +1 @@ | ||||||
|  | DROP TABLE IF EXISTS sso_users; | ||||||
							
								
								
									
										7
									
								
								migrations/sqlite/2024-03-06-170000_add_sso_users/up.sql
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								migrations/sqlite/2024-03-06-170000_add_sso_users/up.sql
									
										
									
									
									
										Normal file
									
								
							|  | @ -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 +1,9 @@ | ||||||
|  | DROP TABLE IF EXISTS sso_users; | ||||||
|  | 
 | ||||||
|  | CREATE TABLE sso_users ( | ||||||
|  |   user_uuid           CHAR(36) NOT NULL PRIMARY KEY, | ||||||
|  |   identifier          TEXT NOT NULL UNIQUE, | ||||||
|  |   created_at          TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||||||
|  | 
 | ||||||
|  |   FOREIGN KEY(user_uuid) REFERENCES users(uuid) ON UPDATE CASCADE ON DELETE CASCADE | ||||||
|  | ); | ||||||
							
								
								
									
										64
									
								
								playwright/.env.template
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								playwright/.env.template
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,64 @@ | ||||||
|  | ################################# | ||||||
|  | ### Conf to run dev instances ### | ||||||
|  | ################################# | ||||||
|  | ENV=dev | ||||||
|  | DC_ENV_FILE=.env | ||||||
|  | COMPOSE_IGNORE_ORPHANS=True | ||||||
|  | DOCKER_BUILDKIT=1 | ||||||
|  | 
 | ||||||
|  | ################ | ||||||
|  | # Users Config # | ||||||
|  | ################ | ||||||
|  | TEST_USER=test | ||||||
|  | TEST_USER_PASSWORD=${TEST_USER} | ||||||
|  | TEST_USER_MAIL=${TEST_USER}@yopmail.com | ||||||
|  | 
 | ||||||
|  | TEST_USER2=test2 | ||||||
|  | TEST_USER2_PASSWORD=${TEST_USER2} | ||||||
|  | TEST_USER2_MAIL=${TEST_USER2}@yopmail.com | ||||||
|  | 
 | ||||||
|  | TEST_USER3=test3 | ||||||
|  | TEST_USER3_PASSWORD=${TEST_USER3} | ||||||
|  | TEST_USER3_MAIL=${TEST_USER3}@yopmail.com | ||||||
|  | 
 | ||||||
|  | ################### | ||||||
|  | # Keycloak Config # | ||||||
|  | ################### | ||||||
|  | KEYCLOAK_ADMIN=admin | ||||||
|  | KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN} | ||||||
|  | KC_HTTP_HOST=127.0.0.1 | ||||||
|  | KC_HTTP_PORT=8080 | ||||||
|  | 
 | ||||||
|  | # Script parameters (use Keycloak and Vaultwarden config too) | ||||||
|  | TEST_REALM=test | ||||||
|  | DUMMY_REALM=dummy | ||||||
|  | DUMMY_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${DUMMY_REALM} | ||||||
|  | 
 | ||||||
|  | ###################### | ||||||
|  | # Vaultwarden Config # | ||||||
|  | ###################### | ||||||
|  | ROCKET_ADDRESS=0.0.0.0 | ||||||
|  | ROCKET_PORT=8000 | ||||||
|  | DOMAIN=http://127.0.0.1:${ROCKET_PORT} | ||||||
|  | LOG_LEVEL=info,oidcwarden::sso=debug | ||||||
|  | I_REALLY_WANT_VOLATILE_STORAGE=true | ||||||
|  | 
 | ||||||
|  | SSO_ENABLED=true | ||||||
|  | SSO_ONLY=false | ||||||
|  | SSO_CLIENT_ID=warden | ||||||
|  | SSO_CLIENT_SECRET=warden | ||||||
|  | SSO_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${TEST_REALM} | ||||||
|  | 
 | ||||||
|  | SMTP_HOST=127.0.0.1 | ||||||
|  | SMTP_PORT=1025 | ||||||
|  | SMTP_SECURITY=off | ||||||
|  | SMTP_TIMEOUT=5 | ||||||
|  | SMTP_FROM=vaultwarden@test | ||||||
|  | SMTP_FROM_NAME=Vaultwarden | ||||||
|  | 
 | ||||||
|  | ######################################################## | ||||||
|  | # DUMMY values for docker-compose to stop bothering us # | ||||||
|  | ######################################################## | ||||||
|  | MARIADB_PORT=3305 | ||||||
|  | MYSQL_PORT=3307 | ||||||
|  | POSTGRES_PORT=5432 | ||||||
							
								
								
									
										6
									
								
								playwright/.gitignore
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								playwright/.gitignore
									
										
									
									
										vendored
									
									
										Normal file
									
								
							|  | @ -0,0 +1,6 @@ | ||||||
|  | logs | ||||||
|  | node_modules/ | ||||||
|  | /test-results/ | ||||||
|  | /playwright-report/ | ||||||
|  | /playwright/.cache/ | ||||||
|  | temp | ||||||
							
								
								
									
										166
									
								
								playwright/README.md
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										166
									
								
								playwright/README.md
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,166 @@ | ||||||
|  | # Integration tests | ||||||
|  | 
 | ||||||
|  | This allows running integration tests using [Playwright](https://playwright.dev/). | ||||||
|  | \ | ||||||
|  | It usse its own [test.env](/test/scenarios/test.env) with different ports to not collide with a running dev instance. | ||||||
|  | 
 | ||||||
|  | ## Install | ||||||
|  | 
 | ||||||
|  | This rely on `docker` and the `compose` [plugin](https://docs.docker.com/compose/install/). | ||||||
|  | Databases (`Mariadb`, `Mysql` and `Postgres`) and `Playwright` will run in containers. | ||||||
|  | 
 | ||||||
|  | ### Running Playwright outside docker | ||||||
|  | 
 | ||||||
|  | It's possible to run `Playwright` outside of the container, this remove the need to rebuild the image for each change. | ||||||
|  | You'll additionally need `nodejs` then run: | ||||||
|  | 
 | ||||||
|  | ```bash | ||||||
|  | npm install | ||||||
|  | npx playwright install-deps | ||||||
|  | npx playwright install firefox | ||||||
|  | ``` | ||||||
|  | 
 | ||||||
|  | ## Usage | ||||||
|  | 
 | ||||||
|  | To run all the tests: | ||||||
|  | 
 | ||||||
|  | ```bash | ||||||
|  | DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright | ||||||
|  | ``` | ||||||
|  | 
 | ||||||
|  | To force a rebuild of the Playwright image: | ||||||
|  | ```bash | ||||||
|  | DOCKER_BUILDKIT=1 docker compose --env-file test.env build Playwright | ||||||
|  | ``` | ||||||
|  | 
 | ||||||
|  | To access the ui to easily run test individually and debug if needed (will not work in docker): | ||||||
|  | 
 | ||||||
|  | ```bash | ||||||
|  | npx playwright test --ui | ||||||
|  | ``` | ||||||
|  | 
 | ||||||
|  | ### DB | ||||||
|  | 
 | ||||||
|  | Projects are configured to allow to run tests only on specific database. | ||||||
|  | \ | ||||||
|  | You can use: | ||||||
|  | 
 | ||||||
|  | ```bash | ||||||
|  | DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=mariadb | ||||||
|  | DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=mysql | ||||||
|  | DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=postgres | ||||||
|  | DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite | ||||||
|  | ``` | ||||||
|  | 
 | ||||||
|  | ### SSO | ||||||
|  | 
 | ||||||
|  | To run the SSO tests: | ||||||
|  | 
 | ||||||
|  | ```bash | ||||||
|  | DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project sso-sqlite | ||||||
|  | ``` | ||||||
|  | 
 | ||||||
|  | ### Keep services running | ||||||
|  | 
 | ||||||
|  | If you want you can keep the Db and Keycloak runnning (states are not impacted by the tests): | ||||||
|  | 
 | ||||||
|  | ```bash | ||||||
|  | PW_KEEP_SERVICE_RUNNNING=true npx playwright test | ||||||
|  | ``` | ||||||
|  | 
 | ||||||
|  | ### Running specific tests | ||||||
|  | 
 | ||||||
|  | To run a whole file you can : | ||||||
|  | 
 | ||||||
|  | ```bash | ||||||
|  | DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite tests/login.spec.ts | ||||||
|  | DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite login | ||||||
|  | ``` | ||||||
|  | 
 | ||||||
|  | To run only a specifc test (It might fail if it has dependency): | ||||||
|  | 
 | ||||||
|  | ```bash | ||||||
|  | DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite -g "Account creation" | ||||||
|  | DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite tests/login.spec.ts:16 | ||||||
|  | ``` | ||||||
|  | 
 | ||||||
|  | ## Writing scenario | ||||||
|  | 
 | ||||||
|  | When creating new scenario use the recorder to more easily identify elements (in general try to rely on visible hint to identify elements and not hidden ids). | ||||||
|  | This does not start the server, you will need to start it manually. | ||||||
|  | 
 | ||||||
|  | ```bash | ||||||
|  | npx playwright codegen "http://127.0.0.1:8000" | ||||||
|  | ``` | ||||||
|  | 
 | ||||||
|  | ## Override web-vault | ||||||
|  | 
 | ||||||
|  | It's possible to change the `web-vault` used by referencing a different `bw_web_builds` commit. | ||||||
|  | 
 | ||||||
|  | ```bash | ||||||
|  | export PW_WV_REPO_URL=https://github.com/Timshel/oidc_web_builds.git | ||||||
|  | export PW_WV_COMMIT_HASH=8707dc76df3f0cceef2be5bfae37bb29bd17fae6 | ||||||
|  | DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env build Playwright | ||||||
|  | ``` | ||||||
|  | 
 | ||||||
|  | # OpenID Connect test setup | ||||||
|  | 
 | ||||||
|  | Additionally this `docker-compose` template allow to run locally `VaultWarden`, [Keycloak](https://www.keycloak.org/) and [Maildev](https://github.com/timshel/maildev) to test OIDC. | ||||||
|  | 
 | ||||||
|  | ## Setup | ||||||
|  | 
 | ||||||
|  | This rely on `docker` and the `compose` [plugin](https://docs.docker.com/compose/install/). | ||||||
|  | First create a copy of `.env.template` as `.env` (This is done to prevent commiting your custom settings, Ex `SMTP_`). | ||||||
|  | 
 | ||||||
|  | ## Usage | ||||||
|  | 
 | ||||||
|  | Then start the stack (the `profile` is required to run `Vaultwarden`) : | ||||||
|  | 
 | ||||||
|  | ```bash | ||||||
|  | > docker compose --profile vaultwarden --env-file .env up | ||||||
|  | .... | ||||||
|  | keycloakSetup_1  | Logging into http://127.0.0.1:8080 as user admin of realm master | ||||||
|  | keycloakSetup_1  | Created new realm with id 'test' | ||||||
|  | keycloakSetup_1  | 74af4933-e386-4e64-ba15-a7b61212c45e | ||||||
|  | oidc_keycloakSetup_1 exited with code 0 | ||||||
|  | ``` | ||||||
|  | 
 | ||||||
|  | Wait until `oidc_keycloakSetup_1 exited with code 0` which indicate the correct setup of the Keycloak realm, client and user (It's normal for this container to stop once the configuration is done). | ||||||
|  | 
 | ||||||
|  | Then you can access : | ||||||
|  | 
 | ||||||
|  | - `VaultWarden` on http://0.0.0.0:8000 with the default user `test@yopmail.com/test`. | ||||||
|  | - `Keycloak` on http://0.0.0.0:8080/admin/master/console/ with the default user `admin/admin` | ||||||
|  | - `Maildev` on http://0.0.0.0:1080 | ||||||
|  | 
 | ||||||
|  | To proceed with an SSO login after you enter the email, on the screen prompting for `Master Password` the SSO button should be visible. | ||||||
|  | To use your computer external ip (for example when testing with a phone) you will have to configure `KC_HTTP_HOST` and `DOMAIN`. | ||||||
|  | 
 | ||||||
|  | ## Running only Keycloak | ||||||
|  | 
 | ||||||
|  | You can run just `Keycloak` with `--profile keycloak`: | ||||||
|  | 
 | ||||||
|  | ```bash | ||||||
|  | > docker compose --profile keycloak --env-file .env up | ||||||
|  | ``` | ||||||
|  | When running with a local VaultWarden, you can use a front-end build from [dani-garcia/bw_web_builds](https://github.com/dani-garcia/bw_web_builds/releases). | ||||||
|  | 
 | ||||||
|  | ## Rebuilding the Vaultwarden | ||||||
|  | 
 | ||||||
|  | To force rebuilding the Vaultwarden image you can run | ||||||
|  | 
 | ||||||
|  | ```bash | ||||||
|  | docker compose --profile vaultwarden --env-file .env build VaultwardenPrebuild Vaultwarden | ||||||
|  | ``` | ||||||
|  | 
 | ||||||
|  | ## Configuration | ||||||
|  | 
 | ||||||
|  | All configuration for `keycloak` / `VaultWarden` / `keycloak_setup.sh` can be found in [.env](.env.template). | ||||||
|  | The content of the file will be loaded as environment variables in all containers. | ||||||
|  | 
 | ||||||
|  | - `keycloak` [configuration](https://www.keycloak.org/server/all-config) include `KEYCLOAK_ADMIN` / `KEYCLOAK_ADMIN_PASSWORD` and any variable prefixed `KC_` ([more information](https://www.keycloak.org/server/configuration#_example_configuring_the_db_url_host_parameter)). | ||||||
|  | - All `VaultWarden` configuration can be set (EX: `SMTP_*`) | ||||||
|  | 
 | ||||||
|  | ## Cleanup | ||||||
|  | 
 | ||||||
|  | Use `docker compose --profile vaultWarden down`. | ||||||
							
								
								
									
										40
									
								
								playwright/compose/keycloak/Dockerfile
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								playwright/compose/keycloak/Dockerfile
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,40 @@ | ||||||
|  | FROM docker.io/library/debian:bookworm-slim as build | ||||||
|  | 
 | ||||||
|  | ENV DEBIAN_FRONTEND=noninteractive | ||||||
|  | ARG KEYCLOAK_VERSION | ||||||
|  | 
 | ||||||
|  | SHELL ["/bin/bash", "-o", "pipefail", "-c"] | ||||||
|  | 
 | ||||||
|  | RUN apt-get update \ | ||||||
|  |     && apt-get install -y ca-certificates curl wget \ | ||||||
|  |     && rm -rf /var/lib/apt/lists/* | ||||||
|  | 
 | ||||||
|  | WORKDIR / | ||||||
|  | 
 | ||||||
|  | RUN wget -c https://github.com/keycloak/keycloak/releases/download/${KEYCLOAK_VERSION}/keycloak-${KEYCLOAK_VERSION}.tar.gz -O - | tar -xz | ||||||
|  | 
 | ||||||
|  | FROM docker.io/library/debian:bookworm-slim | ||||||
|  | 
 | ||||||
|  | ENV DEBIAN_FRONTEND=noninteractive | ||||||
|  | ARG KEYCLOAK_VERSION | ||||||
|  | 
 | ||||||
|  | SHELL ["/bin/bash", "-o", "pipefail", "-c"] | ||||||
|  | 
 | ||||||
|  | RUN apt-get update \ | ||||||
|  |     && apt-get install -y ca-certificates curl wget \ | ||||||
|  |     && rm -rf /var/lib/apt/lists/* | ||||||
|  | 
 | ||||||
|  | ARG JAVA_URL | ||||||
|  | ARG JAVA_VERSION | ||||||
|  | 
 | ||||||
|  | ENV JAVA_VERSION=${JAVA_VERSION} | ||||||
|  | 
 | ||||||
|  | RUN mkdir -p /opt/openjdk && cd /opt/openjdk \ | ||||||
|  |     && wget -c "${JAVA_URL}"  -O - | tar -xz | ||||||
|  | 
 | ||||||
|  | WORKDIR / | ||||||
|  | 
 | ||||||
|  | COPY setup.sh /setup.sh | ||||||
|  | COPY --from=build /keycloak-${KEYCLOAK_VERSION}/bin /opt/keycloak/bin | ||||||
|  | 
 | ||||||
|  | CMD "/setup.sh" | ||||||
							
								
								
									
										36
									
								
								playwright/compose/keycloak/setup.sh
									
										
									
									
									
										Executable file
									
								
							
							
						
						
									
										36
									
								
								playwright/compose/keycloak/setup.sh
									
										
									
									
									
										Executable file
									
								
							|  | @ -0,0 +1,36 @@ | ||||||
|  | #!/bin/bash | ||||||
|  | 
 | ||||||
|  | export PATH=/opt/keycloak/bin:/opt/openjdk/jdk-${JAVA_VERSION}/bin:$PATH | ||||||
|  | export JAVA_HOME=/opt/openjdk/jdk-${JAVA_VERSION} | ||||||
|  | 
 | ||||||
|  | STATUS_CODE=0 | ||||||
|  | while [[ "$STATUS_CODE" != "404" ]] ; do | ||||||
|  |     echo "Will retry in 2 seconds" | ||||||
|  |     sleep 2 | ||||||
|  | 
 | ||||||
|  |     STATUS_CODE=$(curl -s -o /dev/null -w "%{http_code}"  "$DUMMY_AUTHORITY") | ||||||
|  | 
 | ||||||
|  |     if [[ "$STATUS_CODE" = "200" ]]; then | ||||||
|  |         echo "Setup should already be done. Will not run." | ||||||
|  |         exit 0 | ||||||
|  |     fi | ||||||
|  | done | ||||||
|  | 
 | ||||||
|  | set -e | ||||||
|  | 
 | ||||||
|  | kcadm.sh config credentials --server "http://${KC_HTTP_HOST}:${KC_HTTP_PORT}" --realm master --user "$KEYCLOAK_ADMIN" --password "$KEYCLOAK_ADMIN_PASSWORD" --client admin-cli | ||||||
|  | 
 | ||||||
|  | kcadm.sh create realms -s realm="$TEST_REALM" -s enabled=true -s "accessTokenLifespan=600" | ||||||
|  | kcadm.sh create clients -r test -s "clientId=$SSO_CLIENT_ID" -s "secret=$SSO_CLIENT_SECRET" -s "redirectUris=[\"$DOMAIN/*\"]" -i | ||||||
|  | 
 | ||||||
|  | TEST_USER_ID=$(kcadm.sh create users -r "$TEST_REALM" -s "username=$TEST_USER" -s "firstName=$TEST_USER" -s "lastName=$TEST_USER" -s "email=$TEST_USER_MAIL"  -s emailVerified=true -s enabled=true -i) | ||||||
|  | kcadm.sh update users/$TEST_USER_ID/reset-password -r "$TEST_REALM" -s type=password -s "value=$TEST_USER_PASSWORD" -n | ||||||
|  | 
 | ||||||
|  | TEST_USER2_ID=$(kcadm.sh create users -r "$TEST_REALM" -s "username=$TEST_USER2" -s "firstName=$TEST_USER2" -s "lastName=$TEST_USER2" -s "email=$TEST_USER2_MAIL"  -s emailVerified=true -s enabled=true -i) | ||||||
|  | kcadm.sh update users/$TEST_USER2_ID/reset-password -r "$TEST_REALM" -s type=password -s "value=$TEST_USER2_PASSWORD" -n | ||||||
|  | 
 | ||||||
|  | TEST_USER3_ID=$(kcadm.sh create users -r "$TEST_REALM" -s "username=$TEST_USER3" -s "firstName=$TEST_USER3" -s "lastName=$TEST_USER3" -s "email=$TEST_USER3_MAIL"  -s emailVerified=true -s enabled=true -i) | ||||||
|  | kcadm.sh update users/$TEST_USER3_ID/reset-password -r "$TEST_REALM" -s type=password -s "value=$TEST_USER3_PASSWORD" -n | ||||||
|  | 
 | ||||||
|  | # Dummy realm to mark end of setup | ||||||
|  | kcadm.sh create realms -s realm="$DUMMY_REALM" -s enabled=true -s "accessTokenLifespan=600" | ||||||
							
								
								
									
										40
									
								
								playwright/compose/playwright/Dockerfile
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								playwright/compose/playwright/Dockerfile
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,40 @@ | ||||||
|  | FROM docker.io/library/debian:bookworm-slim | ||||||
|  | 
 | ||||||
|  | SHELL ["/bin/bash", "-o", "pipefail", "-c"] | ||||||
|  | 
 | ||||||
|  | ENV DEBIAN_FRONTEND=noninteractive | ||||||
|  | 
 | ||||||
|  | RUN apt-get update \ | ||||||
|  |     && apt-get install -y ca-certificates curl \ | ||||||
|  |     && curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc \ | ||||||
|  |     && chmod a+r /etc/apt/keyrings/docker.asc \ | ||||||
|  |     && echo "deb [signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable" | tee /etc/apt/sources.list.d/docker.list \ | ||||||
|  |     && apt-get update \ | ||||||
|  |     && apt-get install -y --no-install-recommends \ | ||||||
|  |         containerd.io \ | ||||||
|  |         docker-buildx-plugin \ | ||||||
|  |         docker-ce \ | ||||||
|  |         docker-ce-cli \ | ||||||
|  |         docker-compose-plugin \ | ||||||
|  |         git \ | ||||||
|  |         libmariadb-dev-compat \ | ||||||
|  |         libpq5 \ | ||||||
|  |         nodejs \ | ||||||
|  |         npm \ | ||||||
|  |         openssl \ | ||||||
|  |     && rm -rf /var/lib/apt/lists/* | ||||||
|  | 
 | ||||||
|  | RUN mkdir /playwright | ||||||
|  | WORKDIR /playwright | ||||||
|  | 
 | ||||||
|  | COPY package.json . | ||||||
|  | RUN npm install && npx playwright install-deps && npx playwright install firefox | ||||||
|  | 
 | ||||||
|  | COPY docker-compose.yml test.env ./ | ||||||
|  | COPY compose ./compose | ||||||
|  | 
 | ||||||
|  | COPY *.ts test.env ./ | ||||||
|  | COPY tests ./tests | ||||||
|  | 
 | ||||||
|  | ENTRYPOINT ["/usr/bin/npx", "playwright"] | ||||||
|  | CMD ["test"] | ||||||
							
								
								
									
										40
									
								
								playwright/compose/warden/Dockerfile
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								playwright/compose/warden/Dockerfile
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,40 @@ | ||||||
|  | FROM playwright_oidc_vaultwarden_prebuilt AS prebuilt | ||||||
|  | 
 | ||||||
|  | FROM node:18-bookworm AS build | ||||||
|  | 
 | ||||||
|  | ARG REPO_URL | ||||||
|  | ARG COMMIT_HASH | ||||||
|  | 
 | ||||||
|  | ENV REPO_URL=$REPO_URL | ||||||
|  | ENV COMMIT_HASH=$COMMIT_HASH | ||||||
|  | 
 | ||||||
|  | COPY --from=prebuilt /web-vault /web-vault | ||||||
|  | 
 | ||||||
|  | COPY build.sh /build.sh | ||||||
|  | RUN /build.sh | ||||||
|  | 
 | ||||||
|  | ######################## RUNTIME IMAGE  ######################## | ||||||
|  | FROM docker.io/library/debian:bookworm-slim | ||||||
|  | 
 | ||||||
|  | ENV DEBIAN_FRONTEND=noninteractive | ||||||
|  | 
 | ||||||
|  | # Create data folder and Install needed libraries | ||||||
|  | RUN mkdir /data && \ | ||||||
|  |     apt-get update && apt-get install -y \ | ||||||
|  |         --no-install-recommends \ | ||||||
|  |         ca-certificates \ | ||||||
|  |         curl \ | ||||||
|  |         libmariadb-dev-compat \ | ||||||
|  |         libpq5 \ | ||||||
|  |         openssl && \ | ||||||
|  |     rm -rf /var/lib/apt/lists/* | ||||||
|  | 
 | ||||||
|  | # Copies the files from the context (Rocket.toml file and web-vault) | ||||||
|  | # and the binary from the "build" stage to the current stage | ||||||
|  | WORKDIR / | ||||||
|  | 
 | ||||||
|  | COPY --from=prebuilt /start.sh . | ||||||
|  | COPY --from=prebuilt /vaultwarden . | ||||||
|  | COPY --from=build /web-vault ./web-vault | ||||||
|  | 
 | ||||||
|  | ENTRYPOINT ["/start.sh"] | ||||||
							
								
								
									
										24
									
								
								playwright/compose/warden/build.sh
									
										
									
									
									
										Executable file
									
								
							
							
						
						
									
										24
									
								
								playwright/compose/warden/build.sh
									
										
									
									
									
										Executable file
									
								
							|  | @ -0,0 +1,24 @@ | ||||||
|  | #!/bin/bash | ||||||
|  | 
 | ||||||
|  | echo $REPO_URL | ||||||
|  | echo $COMMIT_HASH | ||||||
|  | 
 | ||||||
|  | if [[ ! -z "$REPO_URL" ]] && [[ ! -z "$COMMIT_HASH" ]] ; then | ||||||
|  |     rm -rf /web-vault | ||||||
|  | 
 | ||||||
|  |     mkdir bw_web_builds; | ||||||
|  |     cd bw_web_builds; | ||||||
|  | 
 | ||||||
|  |     git -c init.defaultBranch=main init | ||||||
|  |     git remote add origin "$REPO_URL" | ||||||
|  |     git fetch --depth 1 origin "$COMMIT_HASH" | ||||||
|  |     git -c advice.detachedHead=false checkout FETCH_HEAD | ||||||
|  | 
 | ||||||
|  |     export VAULT_VERSION=$(cat Dockerfile | grep "ARG VAULT_VERSION" | cut -d "=" -f2) | ||||||
|  |     ./scripts/checkout_web_vault.sh | ||||||
|  |     ./scripts/patch_web_vault.sh | ||||||
|  |     ./scripts/build_web_vault.sh | ||||||
|  |     printf '{"version":"%s"}' "$COMMIT_HASH" > ./web-vault/apps/web/build/vw-version.json | ||||||
|  | 
 | ||||||
|  |     mv ./web-vault/apps/web/build /web-vault | ||||||
|  | fi | ||||||
							
								
								
									
										124
									
								
								playwright/docker-compose.yml
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										124
									
								
								playwright/docker-compose.yml
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,124 @@ | ||||||
|  | services: | ||||||
|  |   VaultwardenPrebuild: | ||||||
|  |     profiles: ["playwright", "vaultwarden"] | ||||||
|  |     container_name: playwright_oidc_vaultwarden_prebuilt | ||||||
|  |     image: playwright_oidc_vaultwarden_prebuilt | ||||||
|  |     build: | ||||||
|  |       context: .. | ||||||
|  |       dockerfile: Dockerfile | ||||||
|  |     entrypoint: /bin/bash | ||||||
|  |     restart: "no" | ||||||
|  | 
 | ||||||
|  |   Vaultwarden: | ||||||
|  |     profiles: ["playwright", "vaultwarden"] | ||||||
|  |     container_name: playwright_oidc_vaultwarden-${ENV:-dev} | ||||||
|  |     image: playwright_oidc_vaultwarden-${ENV:-dev} | ||||||
|  |     network_mode: "host" | ||||||
|  |     build: | ||||||
|  |       context: compose/warden | ||||||
|  |       dockerfile: Dockerfile | ||||||
|  |       args: | ||||||
|  |         REPO_URL: ${PW_WV_REPO_URL:-} | ||||||
|  |         COMMIT_HASH: ${PW_WV_COMMIT_HASH:-} | ||||||
|  |     env_file: ${DC_ENV_FILE:-.env} | ||||||
|  |     environment: | ||||||
|  |       - DATABASE_URL | ||||||
|  |       - I_REALLY_WANT_VOLATILE_STORAGE | ||||||
|  |       - LOG_LEVEL | ||||||
|  |       - LOGIN_RATELIMIT_MAX_BURST | ||||||
|  |       - SMTP_HOST | ||||||
|  |       - SMTP_FROM | ||||||
|  |       - SMTP_DEBUG | ||||||
|  |       - SSO_DEBUG_TOKENS | ||||||
|  |       - SSO_FRONTEND | ||||||
|  |       - SSO_ENABLED | ||||||
|  |       - SSO_ONLY | ||||||
|  |     restart: "no" | ||||||
|  |     depends_on: | ||||||
|  |       - VaultwardenPrebuild | ||||||
|  | 
 | ||||||
|  |   Playwright: | ||||||
|  |     profiles: ["playwright"] | ||||||
|  |     container_name: playwright_oidc_playwright | ||||||
|  |     image: playwright_oidc_playwright | ||||||
|  |     network_mode: "host" | ||||||
|  |     build: | ||||||
|  |       context: . | ||||||
|  |       dockerfile: compose/playwright/Dockerfile | ||||||
|  |     environment: | ||||||
|  |       - PW_WV_REPO_URL | ||||||
|  |       - PW_WV_COMMIT_HASH | ||||||
|  |     restart: "no" | ||||||
|  |     volumes: | ||||||
|  |       - /var/run/docker.sock:/var/run/docker.sock | ||||||
|  |       - ..:/project | ||||||
|  | 
 | ||||||
|  |   Mariadb: | ||||||
|  |     profiles: ["playwright"] | ||||||
|  |     container_name: playwright_mariadb | ||||||
|  |     image: mariadb:11.2.4 | ||||||
|  |     env_file: test.env | ||||||
|  |     healthcheck: | ||||||
|  |       test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] | ||||||
|  |       start_period: 10s | ||||||
|  |       interval: 10s | ||||||
|  |     ports: | ||||||
|  |       - ${MARIADB_PORT}:3306 | ||||||
|  | 
 | ||||||
|  |   Mysql: | ||||||
|  |     profiles: ["playwright"] | ||||||
|  |     container_name: playwright_mysql | ||||||
|  |     image: mysql:8.4.1 | ||||||
|  |     env_file: test.env | ||||||
|  |     healthcheck: | ||||||
|  |       test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"] | ||||||
|  |       start_period: 10s | ||||||
|  |       interval: 10s | ||||||
|  |     ports: | ||||||
|  |       - ${MYSQL_PORT}:3306 | ||||||
|  | 
 | ||||||
|  |   Postgres: | ||||||
|  |     profiles: ["playwright"] | ||||||
|  |     container_name: playwright_postgres | ||||||
|  |     image: postgres:16.3 | ||||||
|  |     env_file: test.env | ||||||
|  |     healthcheck: | ||||||
|  |       test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"] | ||||||
|  |       start_period: 20s | ||||||
|  |       interval: 30s | ||||||
|  |     ports: | ||||||
|  |       - ${POSTGRES_PORT}:5432 | ||||||
|  | 
 | ||||||
|  |   Maildev: | ||||||
|  |     profiles: ["vaultwarden", "maildev"] | ||||||
|  |     container_name: maildev | ||||||
|  |     image: timshel/maildev:3.0.4 | ||||||
|  |     ports: | ||||||
|  |       - ${SMTP_PORT}:1025 | ||||||
|  |       - 1080:1080 | ||||||
|  | 
 | ||||||
|  |   Keycloak: | ||||||
|  |     profiles: ["keycloak", "vaultwarden"] | ||||||
|  |     container_name: keycloak-${ENV:-dev} | ||||||
|  |     image: quay.io/keycloak/keycloak:25.0.4 | ||||||
|  |     network_mode: "host" | ||||||
|  |     command: | ||||||
|  |       - start-dev | ||||||
|  |     env_file: ${DC_ENV_FILE:-.env} | ||||||
|  | 
 | ||||||
|  |   KeycloakSetup: | ||||||
|  |     profiles: ["keycloak", "vaultwarden"] | ||||||
|  |     container_name: keycloakSetup-${ENV:-dev} | ||||||
|  |     image: keycloak_setup-${ENV:-dev} | ||||||
|  |     build: | ||||||
|  |       context: compose/keycloak | ||||||
|  |       dockerfile: Dockerfile | ||||||
|  |       args: | ||||||
|  |         KEYCLOAK_VERSION: 25.0.4 | ||||||
|  |         JAVA_URL: https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_linux-x64_bin.tar.gz | ||||||
|  |         JAVA_VERSION: 21.0.2 | ||||||
|  |     network_mode: "host" | ||||||
|  |     depends_on: | ||||||
|  |       - Keycloak | ||||||
|  |     restart: "no" | ||||||
|  |     env_file: ${DC_ENV_FILE:-.env} | ||||||
							
								
								
									
										22
									
								
								playwright/global-setup.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								playwright/global-setup.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,22 @@ | ||||||
|  | import { firefox, type FullConfig } from '@playwright/test'; | ||||||
|  | import { execSync } from 'node:child_process'; | ||||||
|  | import fs from 'fs'; | ||||||
|  | 
 | ||||||
|  | const utils = require('./global-utils'); | ||||||
|  | 
 | ||||||
|  | utils.loadEnv(); | ||||||
|  | 
 | ||||||
|  | async function globalSetup(config: FullConfig) { | ||||||
|  |     // Are we running in docker and the project is mounted ?
 | ||||||
|  |     const path = (fs.existsSync("/project/playwright/playwright.config.ts") ? "/project/playwright" : "."); | ||||||
|  |     execSync(`docker compose --project-directory ${path} --profile playwright --env-file test.env build VaultwardenPrebuild`, { | ||||||
|  |         env: { ...process.env }, | ||||||
|  |         stdio: "inherit" | ||||||
|  |     }); | ||||||
|  |     execSync(`docker compose --project-directory ${path} --profile playwright --env-file test.env build Vaultwarden`, { | ||||||
|  |         env: { ...process.env }, | ||||||
|  |         stdio: "inherit" | ||||||
|  |     }); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export default globalSetup; | ||||||
							
								
								
									
										246
									
								
								playwright/global-utils.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										246
									
								
								playwright/global-utils.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,246 @@ | ||||||
|  | import { expect, type Browser, type TestInfo } from '@playwright/test'; | ||||||
|  | import { EventEmitter } from "events"; | ||||||
|  | import { type Mail, MailServer } from 'maildev'; | ||||||
|  | import { execSync } from 'node:child_process'; | ||||||
|  | 
 | ||||||
|  | import dotenv from 'dotenv'; | ||||||
|  | import dotenvExpand from 'dotenv-expand'; | ||||||
|  | 
 | ||||||
|  | const fs = require("fs"); | ||||||
|  | const { spawn } = require('node:child_process'); | ||||||
|  | 
 | ||||||
|  | export function loadEnv(){ | ||||||
|  |     var myEnv = dotenv.config({ path: 'test.env' }); | ||||||
|  |     dotenvExpand.expand(myEnv); | ||||||
|  | 
 | ||||||
|  |     return { | ||||||
|  |         user1: { | ||||||
|  |             email: process.env.TEST_USER_MAIL, | ||||||
|  |             name: process.env.TEST_USER, | ||||||
|  |             password: process.env.TEST_USER_PASSWORD, | ||||||
|  |         }, | ||||||
|  |         user2: { | ||||||
|  |             email: process.env.TEST_USER2_MAIL, | ||||||
|  |             name: process.env.TEST_USER2, | ||||||
|  |             password: process.env.TEST_USER2_PASSWORD, | ||||||
|  |         }, | ||||||
|  |         user3: { | ||||||
|  |             email: process.env.TEST_USER3_MAIL, | ||||||
|  |             name: process.env.TEST_USER3, | ||||||
|  |             password: process.env.TEST_USER3_PASSWORD, | ||||||
|  |         }, | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export async function waitFor(url: String, browser: Browser) { | ||||||
|  |     var ready = false; | ||||||
|  |     var context; | ||||||
|  | 
 | ||||||
|  |     do { | ||||||
|  |         try { | ||||||
|  |             context = await browser.newContext(); | ||||||
|  |             const page = await context.newPage(); | ||||||
|  |             await page.waitForTimeout(500); | ||||||
|  |             const result = await page.goto(url); | ||||||
|  |             ready = result.status() === 200; | ||||||
|  |         } catch(e) { | ||||||
|  |             if( !e.message.includes("CONNECTION_REFUSED") ){ | ||||||
|  |                 throw e; | ||||||
|  |             } | ||||||
|  |         } finally { | ||||||
|  |             await context.close(); | ||||||
|  |         } | ||||||
|  |     } while(!ready); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function startComposeService(serviceName: String){ | ||||||
|  |     console.log(`Starting ${serviceName}`); | ||||||
|  |     execSync(`docker compose --profile playwright --env-file test.env  up -d ${serviceName}`); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function stopComposeService(serviceName: String){ | ||||||
|  |     console.log(`Stopping ${serviceName}`); | ||||||
|  |     execSync(`docker compose --profile playwright --env-file test.env  stop ${serviceName}`); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function wipeSqlite(){ | ||||||
|  |     console.log(`Delete Vaultwarden container to wipe sqlite`); | ||||||
|  |     execSync(`docker compose --env-file test.env stop Vaultwarden`); | ||||||
|  |     execSync(`docker compose --env-file test.env rm -f Vaultwarden`); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | async function wipeMariaDB(){ | ||||||
|  |     var mysql = require('mysql2/promise'); | ||||||
|  |     var ready = false; | ||||||
|  |     var connection; | ||||||
|  | 
 | ||||||
|  |     do { | ||||||
|  |         try { | ||||||
|  |             connection = await mysql.createConnection({ | ||||||
|  |                 user: process.env.MARIADB_USER, | ||||||
|  |                 host: "127.0.0.1", | ||||||
|  |                 database: process.env.MARIADB_DATABASE, | ||||||
|  |                 password: process.env.MARIADB_PASSWORD, | ||||||
|  |                 port: process.env.MARIADB_PORT, | ||||||
|  |             }); | ||||||
|  | 
 | ||||||
|  |             await connection.execute(`DROP DATABASE ${process.env.MARIADB_DATABASE}`); | ||||||
|  |             await connection.execute(`CREATE DATABASE ${process.env.MARIADB_DATABASE}`); | ||||||
|  |             console.log('Successfully wiped mariadb'); | ||||||
|  |             ready = true; | ||||||
|  |         } catch (err) { | ||||||
|  |             console.log(`Error when wiping mariadb: ${err}`); | ||||||
|  |         } finally { | ||||||
|  |             if( connection ){ | ||||||
|  |                 connection.end(); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         await new Promise(r => setTimeout(r, 1000)); | ||||||
|  |     } while(!ready); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | async function wipeMysqlDB(){ | ||||||
|  |     var mysql = require('mysql2/promise'); | ||||||
|  |     var ready = false; | ||||||
|  |     var connection; | ||||||
|  | 
 | ||||||
|  |     do{ | ||||||
|  |         try { | ||||||
|  |             connection = await mysql.createConnection({ | ||||||
|  |                 user: process.env.MYSQL_USER, | ||||||
|  |                 host: "127.0.0.1", | ||||||
|  |                 database: process.env.MYSQL_DATABASE, | ||||||
|  |                 password: process.env.MYSQL_PASSWORD, | ||||||
|  |                 port: process.env.MYSQL_PORT, | ||||||
|  |             }); | ||||||
|  | 
 | ||||||
|  |             await connection.execute(`DROP DATABASE ${process.env.MYSQL_DATABASE}`); | ||||||
|  |             await connection.execute(`CREATE DATABASE ${process.env.MYSQL_DATABASE}`); | ||||||
|  |             console.log('Successfully wiped mysql'); | ||||||
|  |             ready = true; | ||||||
|  |         } catch (err) { | ||||||
|  |             console.log(`Error when wiping mysql: ${err}`); | ||||||
|  |         } finally { | ||||||
|  |             if( connection ){ | ||||||
|  |                 connection.end(); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         await new Promise(r => setTimeout(r, 1000)); | ||||||
|  |     } while(!ready); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | async function wipePostgres(){ | ||||||
|  |     const { Client } = require('pg'); | ||||||
|  | 
 | ||||||
|  |     const client = new Client({ | ||||||
|  |         user: process.env.POSTGRES_USER, | ||||||
|  |         host: "127.0.0.1", | ||||||
|  |         database: "postgres", | ||||||
|  |         password: process.env.POSTGRES_PASSWORD, | ||||||
|  |         port: process.env.POSTGRES_PORT, | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     try { | ||||||
|  |         await client.connect(); | ||||||
|  |         await client.query(`DROP DATABASE ${process.env.POSTGRES_DB}`); | ||||||
|  |         await client.query(`CREATE DATABASE ${process.env.POSTGRES_DB}`); | ||||||
|  |         console.log('Successfully wiped postgres'); | ||||||
|  |     } catch (err) { | ||||||
|  |         console.log(`Error when wiping postgres: ${err}`); | ||||||
|  |     } finally { | ||||||
|  |         client.end(); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function dbConfig(testInfo: TestInfo){ | ||||||
|  |     switch(testInfo.project.name) { | ||||||
|  |         case "postgres": | ||||||
|  |         case "sso-postgres": | ||||||
|  |             return { DATABASE_URL: `postgresql://${process.env.POSTGRES_USER}:${process.env.POSTGRES_PASSWORD}@127.0.0.1:${process.env.POSTGRES_PORT}/${process.env.POSTGRES_DB}` }; | ||||||
|  |         case "mariadb": | ||||||
|  |         case "sso-mariadb": | ||||||
|  |             return { DATABASE_URL: `mysql://${process.env.MARIADB_USER}:${process.env.MARIADB_PASSWORD}@127.0.0.1:${process.env.MARIADB_PORT}/${process.env.MARIADB_DATABASE}` }; | ||||||
|  |         case "mysql": | ||||||
|  |         case "sso-mysql": | ||||||
|  |             return { DATABASE_URL: `mysql://${process.env.MYSQL_USER}:${process.env.MYSQL_PASSWORD}@127.0.0.1:${process.env.MYSQL_PORT}/${process.env.MYSQL_DATABASE}`}; | ||||||
|  |         case "sqlite": | ||||||
|  |         case "sso-sqlite": | ||||||
|  |             return { I_REALLY_WANT_VOLATILE_STORAGE: true }; | ||||||
|  |         default: | ||||||
|  |             throw new Error(`Unknow database name: ${testInfo.project.name}`); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  *  All parameters passed in `env` need to be added to the docker-compose.yml | ||||||
|  |  **/ | ||||||
|  | export async function startVault(browser: Browser, testInfo: TestInfo, env = {}, resetDB: Boolean = true) { | ||||||
|  |     if( resetDB ){ | ||||||
|  |         switch(testInfo.project.name) { | ||||||
|  |             case "postgres": | ||||||
|  |             case "sso-postgres": | ||||||
|  |                 await wipePostgres(); | ||||||
|  |                 break; | ||||||
|  |             case "mariadb": | ||||||
|  |             case "sso-mariadb": | ||||||
|  |                 await wipeMariaDB(); | ||||||
|  |                 break; | ||||||
|  |             case "mysql": | ||||||
|  |             case "sso-mysql": | ||||||
|  |                 await wipeMysqlDB(); | ||||||
|  |                 break; | ||||||
|  |             case "sqlite": | ||||||
|  |             case "sso-sqlite": | ||||||
|  |                 wipeSqlite(); | ||||||
|  |                 break; | ||||||
|  |             default: | ||||||
|  |                 throw new Error(`Unknow database name: ${testInfo.project.name}`); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     console.log(`Starting Vaultwarden`); | ||||||
|  |     execSync(`docker compose --profile playwright --env-file test.env up -d Vaultwarden`, { | ||||||
|  |         env: { ...env, ...dbConfig(testInfo) }, | ||||||
|  |     }); | ||||||
|  |     await waitFor("/", browser); | ||||||
|  |     console.log(`Vaultwarden running on: ${process.env.DOMAIN}`); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export async function stopVault(force: boolean = false) { | ||||||
|  |     if( force === false && process.env.PW_KEEP_SERVICE_RUNNNING === "true" ) { | ||||||
|  |         console.log(`Keep vaultwarden running on: ${process.env.DOMAIN}`); | ||||||
|  |     } else { | ||||||
|  |         console.log(`Vaultwarden stopping`); | ||||||
|  |         execSync(`docker compose --profile playwright --env-file test.env stop Vaultwarden`); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export async function restartVault(page: Page, testInfo: TestInfo, env, resetDB: Boolean = true) { | ||||||
|  |     stopVault(true); | ||||||
|  |     return startVault(page.context().browser(), testInfo, env, resetDB); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export async function checkNotification(page: Page, hasText: string) { | ||||||
|  |     await expect(page.locator('bit-toast').filter({ hasText })).toBeVisible(); | ||||||
|  |     await page.locator('bit-toast').filter({ hasText }).getByRole('button').click(); | ||||||
|  |     await expect(page.locator('bit-toast').filter({ hasText })).toHaveCount(0); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export async function cleanLanding(page: Page) { | ||||||
|  |     await page.goto('/', { waitUntil: 'domcontentloaded' }); | ||||||
|  |     await expect(page.getByRole('button').nth(0)).toBeVisible(); | ||||||
|  | 
 | ||||||
|  |     const logged = await page.getByRole('button', { name: 'Log out' }).count(); | ||||||
|  |     if( logged > 0 ){ | ||||||
|  |         await page.getByRole('button', { name: 'Log out' }).click(); | ||||||
|  |         await page.getByRole('button', { name: 'Log out' }).click(); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export async function logout(test: Test, page: Page, user: { name: string }) { | ||||||
|  |     await test.step('logout', async () => { | ||||||
|  |         await page.getByRole('button', { name: user.name, exact: true }).click(); | ||||||
|  |         await page.getByRole('menuitem', { name: 'Log out' }).click(); | ||||||
|  |         await expect(page.getByRole('heading', { name: 'Log in' })).toBeVisible(); | ||||||
|  |     }); | ||||||
|  | } | ||||||
							
								
								
									
										2547
									
								
								playwright/package-lock.json
									
										
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										2547
									
								
								playwright/package-lock.json
									
										
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							
							
								
								
									
										21
									
								
								playwright/package.json
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								playwright/package.json
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,21 @@ | ||||||
|  | { | ||||||
|  |     "name": "scenarios", | ||||||
|  |     "version": "1.0.0", | ||||||
|  |     "description": "", | ||||||
|  |     "main": "index.js", | ||||||
|  |     "scripts": {}, | ||||||
|  |     "keywords": [], | ||||||
|  |     "author": "", | ||||||
|  |     "license": "ISC", | ||||||
|  |     "devDependencies": { | ||||||
|  |         "@playwright/test": "^1.53.0", | ||||||
|  |         "dotenv": "^16.5.0", | ||||||
|  |         "dotenv-expand": "^12.0.2", | ||||||
|  |         "maildev": "npm:@timshel_npm/maildev@^3.1.2" | ||||||
|  |     }, | ||||||
|  |     "dependencies": { | ||||||
|  |         "mysql2": "^3.14.1", | ||||||
|  |         "otpauth": "^9.4.0", | ||||||
|  |         "pg": "^8.16.0" | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										143
									
								
								playwright/playwright.config.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										143
									
								
								playwright/playwright.config.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,143 @@ | ||||||
|  | import { defineConfig, devices } from '@playwright/test'; | ||||||
|  | import { exec } from 'node:child_process'; | ||||||
|  | 
 | ||||||
|  | const utils = require('./global-utils'); | ||||||
|  | 
 | ||||||
|  | utils.loadEnv(); | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * See https://playwright.dev/docs/test-configuration.
 | ||||||
|  |  */ | ||||||
|  | export default defineConfig({ | ||||||
|  |     testDir: './.', | ||||||
|  |     /* Run tests in files in parallel */ | ||||||
|  |     fullyParallel: false, | ||||||
|  | 
 | ||||||
|  |     /* Fail the build on CI if you accidentally left test.only in the source code. */ | ||||||
|  |     forbidOnly: !!process.env.CI, | ||||||
|  | 
 | ||||||
|  |     retries: 0, | ||||||
|  |     workers: 1, | ||||||
|  | 
 | ||||||
|  |     /* Reporter to use. See https://playwright.dev/docs/test-reporters */ | ||||||
|  |     reporter: 'html', | ||||||
|  | 
 | ||||||
|  |     /* Long global timeout for complex tests | ||||||
|  |      * But short action/nav/expect timeouts to fail on specific step (raise locally if not enough). | ||||||
|  |      */ | ||||||
|  |     timeout: 120 * 1000, | ||||||
|  |     actionTimeout: 10 * 1000, | ||||||
|  |     navigationTimeout: 10 * 1000, | ||||||
|  |     expect: { timeout: 10 * 1000 }, | ||||||
|  | 
 | ||||||
|  |     /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ | ||||||
|  |     use: { | ||||||
|  |         /* Base URL to use in actions like `await page.goto('/')`. */ | ||||||
|  |         baseURL: process.env.DOMAIN, | ||||||
|  |         browserName: 'firefox', | ||||||
|  |         locale: 'en-GB', | ||||||
|  |         timezoneId: 'Europe/London', | ||||||
|  | 
 | ||||||
|  |         /* Always collect trace (other values add random test failures) See https://playwright.dev/docs/trace-viewer */ | ||||||
|  |         trace: 'on', | ||||||
|  |         viewport: { | ||||||
|  |             width: 1080, | ||||||
|  |             height: 720, | ||||||
|  |         }, | ||||||
|  |         video: "on", | ||||||
|  |     }, | ||||||
|  | 
 | ||||||
|  |     /* Configure projects for major browsers */ | ||||||
|  |     projects: [ | ||||||
|  |         { | ||||||
|  |             name: 'mariadb-setup', | ||||||
|  |             testMatch: 'tests/setups/db-setup.ts', | ||||||
|  |             use: { serviceName: "Mariadb" }, | ||||||
|  |             teardown: 'mariadb-teardown', | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |             name: 'mysql-setup', | ||||||
|  |             testMatch: 'tests/setups/db-setup.ts', | ||||||
|  |             use: { serviceName: "Mysql" }, | ||||||
|  |             teardown: 'mysql-teardown', | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |             name: 'postgres-setup', | ||||||
|  |             testMatch: 'tests/setups/db-setup.ts', | ||||||
|  |             use: { serviceName: "Postgres" }, | ||||||
|  |             teardown: 'postgres-teardown', | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |             name: 'sso-setup', | ||||||
|  |             testMatch: 'tests/setups/sso-setup.ts', | ||||||
|  |             teardown: 'sso-teardown', | ||||||
|  |         }, | ||||||
|  | 
 | ||||||
|  |         { | ||||||
|  |             name: 'mariadb', | ||||||
|  |             testMatch: 'tests/*.spec.ts', | ||||||
|  |             testIgnore: 'tests/sso_*.spec.ts', | ||||||
|  |             dependencies: ['mariadb-setup'], | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |             name: 'mysql', | ||||||
|  |             testMatch: 'tests/*.spec.ts', | ||||||
|  |             testIgnore: 'tests/sso_*.spec.ts', | ||||||
|  |             dependencies: ['mysql-setup'], | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |             name: 'postgres', | ||||||
|  |             testMatch: 'tests/*.spec.ts', | ||||||
|  |             testIgnore: 'tests/sso_*.spec.ts', | ||||||
|  |             dependencies: ['postgres-setup'], | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |             name: 'sqlite', | ||||||
|  |             testMatch: 'tests/*.spec.ts', | ||||||
|  |             testIgnore: 'tests/sso_*.spec.ts', | ||||||
|  |         }, | ||||||
|  | 
 | ||||||
|  |         { | ||||||
|  |             name: 'sso-mariadb', | ||||||
|  |             testMatch: 'tests/sso_*.spec.ts', | ||||||
|  |             dependencies: ['sso-setup', 'mariadb-setup'], | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |             name: 'sso-mysql', | ||||||
|  |             testMatch: 'tests/sso_*.spec.ts', | ||||||
|  |             dependencies: ['sso-setup', 'mysql-setup'], | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |             name: 'sso-postgres', | ||||||
|  |             testMatch: 'tests/sso_*.spec.ts', | ||||||
|  |             dependencies: ['sso-setup', 'postgres-setup'], | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |             name: 'sso-sqlite', | ||||||
|  |             testMatch: 'tests/sso_*.spec.ts', | ||||||
|  |             dependencies: ['sso-setup'], | ||||||
|  |         }, | ||||||
|  | 
 | ||||||
|  |         { | ||||||
|  |             name: 'mariadb-teardown', | ||||||
|  |             testMatch: 'tests/setups/db-teardown.ts', | ||||||
|  |             use: { serviceName: "Mariadb" }, | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |             name: 'mysql-teardown', | ||||||
|  |             testMatch: 'tests/setups/db-teardown.ts', | ||||||
|  |             use: { serviceName: "Mysql" }, | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |             name: 'postgres-teardown', | ||||||
|  |             testMatch: 'tests/setups/db-teardown.ts', | ||||||
|  |             use: { serviceName: "Postgres" }, | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |             name: 'sso-teardown', | ||||||
|  |             testMatch: 'tests/setups/sso-teardown.ts', | ||||||
|  |         }, | ||||||
|  |     ], | ||||||
|  | 
 | ||||||
|  |     globalSetup: require.resolve('./global-setup'), | ||||||
|  | }); | ||||||
							
								
								
									
										93
									
								
								playwright/test.env
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										93
									
								
								playwright/test.env
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,93 @@ | ||||||
|  | ################################################################## | ||||||
|  | ### Shared Playwright conf test file Vaultwarden and Databases ### | ||||||
|  | ################################################################## | ||||||
|  | 
 | ||||||
|  | ENV=test | ||||||
|  | DC_ENV_FILE=test.env | ||||||
|  | COMPOSE_IGNORE_ORPHANS=True | ||||||
|  | DOCKER_BUILDKIT=1 | ||||||
|  | 
 | ||||||
|  | ##################### | ||||||
|  | # Playwright Config # | ||||||
|  | ##################### | ||||||
|  | PW_KEEP_SERVICE_RUNNNING=${PW_KEEP_SERVICE_RUNNNING:-false} | ||||||
|  | PW_SMTP_FROM=vaultwarden@playwright.test | ||||||
|  | 
 | ||||||
|  | ##################### | ||||||
|  | # Maildev Config 	# | ||||||
|  | ##################### | ||||||
|  | MAILDEV_HTTP_PORT=1081 | ||||||
|  | MAILDEV_SMTP_PORT=1026 | ||||||
|  | MAILDEV_HOST=127.0.0.1 | ||||||
|  | 
 | ||||||
|  | ################ | ||||||
|  | # Users Config # | ||||||
|  | ################ | ||||||
|  | TEST_USER=test | ||||||
|  | TEST_USER_PASSWORD=Master Password | ||||||
|  | TEST_USER_MAIL=${TEST_USER}@example.com | ||||||
|  | 
 | ||||||
|  | TEST_USER2=test2 | ||||||
|  | TEST_USER2_PASSWORD=Master Password | ||||||
|  | TEST_USER2_MAIL=${TEST_USER2}@example.com | ||||||
|  | 
 | ||||||
|  | TEST_USER3=test3 | ||||||
|  | TEST_USER3_PASSWORD=Master Password | ||||||
|  | TEST_USER3_MAIL=${TEST_USER3}@example.com | ||||||
|  | 
 | ||||||
|  | ################### | ||||||
|  | # Keycloak Config # | ||||||
|  | ################### | ||||||
|  | KEYCLOAK_ADMIN=admin | ||||||
|  | KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN} | ||||||
|  | KC_HTTP_HOST=127.0.0.1 | ||||||
|  | KC_HTTP_PORT=8081 | ||||||
|  | 
 | ||||||
|  | # Script parameters (use Keycloak and VaultWarden config too) | ||||||
|  | TEST_REALM=test | ||||||
|  | DUMMY_REALM=dummy | ||||||
|  | DUMMY_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${DUMMY_REALM} | ||||||
|  | 
 | ||||||
|  | ###################### | ||||||
|  | # Vaultwarden Config # | ||||||
|  | ###################### | ||||||
|  | ROCKET_PORT=8003 | ||||||
|  | DOMAIN=http://127.0.0.1:${ROCKET_PORT} | ||||||
|  | LOG_LEVEL=info,oidcwarden::sso=debug | ||||||
|  | LOGIN_RATELIMIT_MAX_BURST=100 | ||||||
|  | 
 | ||||||
|  | SMTP_SECURITY=off | ||||||
|  | SMTP_PORT=${MAILDEV_SMTP_PORT} | ||||||
|  | SMTP_FROM_NAME=Vaultwarden | ||||||
|  | SMTP_TIMEOUT=5 | ||||||
|  | 
 | ||||||
|  | SSO_CLIENT_ID=warden | ||||||
|  | SSO_CLIENT_SECRET=warden | ||||||
|  | SSO_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${TEST_REALM} | ||||||
|  | SSO_DEBUG_TOKENS=true | ||||||
|  | 
 | ||||||
|  | ########################### | ||||||
|  | # Docker MariaDb container# | ||||||
|  | ########################### | ||||||
|  | MARIADB_PORT=3307 | ||||||
|  | MARIADB_ROOT_PASSWORD=warden | ||||||
|  | MARIADB_USER=warden | ||||||
|  | MARIADB_PASSWORD=warden | ||||||
|  | MARIADB_DATABASE=warden | ||||||
|  | 
 | ||||||
|  | ########################### | ||||||
|  | # Docker Mysql container# | ||||||
|  | ########################### | ||||||
|  | MYSQL_PORT=3309 | ||||||
|  | MYSQL_ROOT_PASSWORD=warden | ||||||
|  | MYSQL_USER=warden | ||||||
|  | MYSQL_PASSWORD=warden | ||||||
|  | MYSQL_DATABASE=warden | ||||||
|  | 
 | ||||||
|  | ############################ | ||||||
|  | # Docker Postgres container# | ||||||
|  | ############################ | ||||||
|  | POSTGRES_PORT=5433 | ||||||
|  | POSTGRES_USER=warden | ||||||
|  | POSTGRES_PASSWORD=warden | ||||||
|  | POSTGRES_DB=warden | ||||||
							
								
								
									
										37
									
								
								playwright/tests/collection.spec.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								playwright/tests/collection.spec.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,37 @@ | ||||||
|  | import { test, expect, type TestInfo } from '@playwright/test'; | ||||||
|  | 
 | ||||||
|  | import * as utils from "../global-utils"; | ||||||
|  | import { createAccount } from './setups/user'; | ||||||
|  | 
 | ||||||
|  | let users = utils.loadEnv(); | ||||||
|  | 
 | ||||||
|  | test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { | ||||||
|  |     await utils.startVault(browser, testInfo); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test.afterAll('Teardown', async ({}) => { | ||||||
|  |     utils.stopVault(); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test('Create', async ({ page }) => { | ||||||
|  |     await createAccount(test, page, users.user1); | ||||||
|  | 
 | ||||||
|  |     await test.step('Create Org', async () => { | ||||||
|  |         await page.getByRole('link', { name: 'New organisation' }).click(); | ||||||
|  |         await page.getByLabel('Organisation name (required)').fill('Test'); | ||||||
|  |         await page.getByRole('button', { name: 'Submit' }).click(); | ||||||
|  |         await page.locator('div').filter({ hasText: 'Members' }).nth(2).click(); | ||||||
|  | 
 | ||||||
|  |         await utils.checkNotification(page, 'Organisation created'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     await test.step('Create Collection', async () => { | ||||||
|  |         await page.getByRole('link', { name: 'Collections' }).click(); | ||||||
|  |         await page.getByRole('button', { name: 'New' }).click(); | ||||||
|  |         await page.getByRole('menuitem', { name: 'Collection' }).click(); | ||||||
|  |         await page.getByLabel('Name (required)').fill('RandomCollec'); | ||||||
|  |         await page.getByRole('button', { name: 'Save' }).click(); | ||||||
|  |         await utils.checkNotification(page, 'Created collection RandomCollec'); | ||||||
|  |         await expect(page.getByRole('button', { name: 'RandomCollec' })).toBeVisible(); | ||||||
|  |     }); | ||||||
|  | }); | ||||||
							
								
								
									
										100
									
								
								playwright/tests/login.smtp.spec.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										100
									
								
								playwright/tests/login.smtp.spec.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,100 @@ | ||||||
|  | import { test, expect, type TestInfo } from '@playwright/test'; | ||||||
|  | import { MailDev } from 'maildev'; | ||||||
|  | 
 | ||||||
|  | const utils = require('../global-utils'); | ||||||
|  | import { createAccount, logUser } from './setups/user'; | ||||||
|  | import { activateEmail, retrieveEmailCode, disableEmail } from './setups/2fa'; | ||||||
|  | 
 | ||||||
|  | let users = utils.loadEnv(); | ||||||
|  | 
 | ||||||
|  | let mailserver; | ||||||
|  | 
 | ||||||
|  | test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { | ||||||
|  |     mailserver = new MailDev({ | ||||||
|  |         port: process.env.MAILDEV_SMTP_PORT, | ||||||
|  |         web: { port: process.env.MAILDEV_HTTP_PORT }, | ||||||
|  |     }) | ||||||
|  | 
 | ||||||
|  |     await mailserver.listen(); | ||||||
|  | 
 | ||||||
|  |     await utils.startVault(browser, testInfo, { | ||||||
|  |         SMTP_HOST: process.env.MAILDEV_HOST, | ||||||
|  |         SMTP_FROM: process.env.PW_SMTP_FROM, | ||||||
|  |     }); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test.afterAll('Teardown', async ({}) => { | ||||||
|  |     utils.stopVault(); | ||||||
|  |     if( mailserver ){ | ||||||
|  |         await mailserver.close(); | ||||||
|  |     } | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test('Account creation', async ({ page }) => { | ||||||
|  |     const mailBuffer = mailserver.buffer(users.user1.email); | ||||||
|  | 
 | ||||||
|  |     await createAccount(test, page, users.user1, mailBuffer); | ||||||
|  | 
 | ||||||
|  |     mailBuffer.close(); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test('Login', async ({ context, page }) => { | ||||||
|  |     const mailBuffer = mailserver.buffer(users.user1.email); | ||||||
|  | 
 | ||||||
|  |     await logUser(test, page, users.user1, mailBuffer); | ||||||
|  | 
 | ||||||
|  |     await test.step('verify email', async () => { | ||||||
|  |         await page.getByText('Verify your account\'s email').click(); | ||||||
|  |         await expect(page.getByText('Verify your account\'s email')).toBeVisible(); | ||||||
|  |         await page.getByRole('button', { name: 'Send email' }).click(); | ||||||
|  | 
 | ||||||
|  |         await utils.checkNotification(page, 'Check your email inbox for a verification link'); | ||||||
|  | 
 | ||||||
|  |         const verify = await mailBuffer.expect((m) => m.subject === "Verify Your Email"); | ||||||
|  |         expect(verify.from[0]?.address).toBe(process.env.PW_SMTP_FROM); | ||||||
|  | 
 | ||||||
|  |         const page2 = await context.newPage(); | ||||||
|  |         await page2.setContent(verify.html); | ||||||
|  |         const link = await page2.getByTestId("verify").getAttribute("href"); | ||||||
|  |         await page2.close(); | ||||||
|  | 
 | ||||||
|  |         await page.goto(link); | ||||||
|  |         await utils.checkNotification(page, 'Account email verified'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     mailBuffer.close(); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test('Activate 2fa', async ({ page }) => { | ||||||
|  |     const emails = mailserver.buffer(users.user1.email); | ||||||
|  | 
 | ||||||
|  |     await logUser(test, page, users.user1); | ||||||
|  | 
 | ||||||
|  |     await activateEmail(test, page, users.user1, emails); | ||||||
|  | 
 | ||||||
|  |     emails.close(); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test('2fa', async ({ page }) => { | ||||||
|  |     const emails = mailserver.buffer(users.user1.email); | ||||||
|  | 
 | ||||||
|  |     await test.step('login', async () => { | ||||||
|  |         await page.goto('/'); | ||||||
|  | 
 | ||||||
|  |         await page.getByLabel(/Email address/).fill(users.user1.email); | ||||||
|  |         await page.getByRole('button', { name: 'Continue' }).click(); | ||||||
|  |         await page.getByLabel('Master password').fill(users.user1.password); | ||||||
|  |         await page.getByRole('button', { name: 'Log in with master password' }).click(); | ||||||
|  | 
 | ||||||
|  |         await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible(); | ||||||
|  |         const code = await retrieveEmailCode(test, page, emails); | ||||||
|  |         await page.getByLabel(/Verification code/).fill(code); | ||||||
|  |         await page.getByRole('button', { name: 'Continue' }).click(); | ||||||
|  | 
 | ||||||
|  |         await expect(page).toHaveTitle(/Vaults/); | ||||||
|  |     }) | ||||||
|  | 
 | ||||||
|  |     await disableEmail(test, page, users.user1); | ||||||
|  | 
 | ||||||
|  |     emails.close(); | ||||||
|  | }); | ||||||
							
								
								
									
										51
									
								
								playwright/tests/login.spec.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								playwright/tests/login.spec.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,51 @@ | ||||||
|  | import { test, expect, type Page, type TestInfo } from '@playwright/test'; | ||||||
|  | import * as OTPAuth from "otpauth"; | ||||||
|  | 
 | ||||||
|  | import * as utils from "../global-utils"; | ||||||
|  | import { createAccount, logUser } from './setups/user'; | ||||||
|  | import { activateTOTP, disableTOTP } from './setups/2fa'; | ||||||
|  | 
 | ||||||
|  | let users = utils.loadEnv(); | ||||||
|  | let totp; | ||||||
|  | 
 | ||||||
|  | test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { | ||||||
|  |     await utils.startVault(browser, testInfo, {}); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test.afterAll('Teardown', async ({}) => { | ||||||
|  |     utils.stopVault(); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test('Account creation', async ({ page }) => { | ||||||
|  |     await createAccount(test, page, users.user1); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test('Master password login', async ({ page }) => { | ||||||
|  |     await logUser(test, page, users.user1); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test('Authenticator 2fa', async ({ page }) => { | ||||||
|  |     await logUser(test, page, users.user1); | ||||||
|  | 
 | ||||||
|  |     let totp = await activateTOTP(test, page, users.user1); | ||||||
|  | 
 | ||||||
|  |     await utils.logout(test, page, users.user1); | ||||||
|  | 
 | ||||||
|  |     await test.step('login', async () => { | ||||||
|  |         let timestamp = Date.now(); // Needed to use the next token
 | ||||||
|  |         timestamp = timestamp + (totp.period - (Math.floor(timestamp / 1000) % totp.period) + 1) * 1000; | ||||||
|  | 
 | ||||||
|  |         await page.getByLabel(/Email address/).fill(users.user1.email); | ||||||
|  |         await page.getByRole('button', { name: 'Continue' }).click(); | ||||||
|  |         await page.getByLabel('Master password').fill(users.user1.password); | ||||||
|  |         await page.getByRole('button', { name: 'Log in with master password' }).click(); | ||||||
|  | 
 | ||||||
|  |         await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible(); | ||||||
|  |         await page.getByLabel(/Verification code/).fill(totp.generate({timestamp})); | ||||||
|  |         await page.getByRole('button', { name: 'Continue' }).click(); | ||||||
|  | 
 | ||||||
|  |         await expect(page).toHaveTitle(/Vaultwarden Web/); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     await disableTOTP(test, page, users.user1); | ||||||
|  | }); | ||||||
							
								
								
									
										115
									
								
								playwright/tests/organization.smtp.spec.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										115
									
								
								playwright/tests/organization.smtp.spec.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,115 @@ | ||||||
|  | import { test, expect, type TestInfo } from '@playwright/test'; | ||||||
|  | import { MailDev } from 'maildev'; | ||||||
|  | 
 | ||||||
|  | import * as utils from '../global-utils'; | ||||||
|  | import * as orgs from './setups/orgs'; | ||||||
|  | import { createAccount, logUser } from './setups/user'; | ||||||
|  | 
 | ||||||
|  | let users = utils.loadEnv(); | ||||||
|  | 
 | ||||||
|  | let mailServer, mail1Buffer, mail2Buffer, mail3Buffer; | ||||||
|  | 
 | ||||||
|  | test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { | ||||||
|  |     mailServer = new MailDev({ | ||||||
|  |         port: process.env.MAILDEV_SMTP_PORT, | ||||||
|  |         web: { port: process.env.MAILDEV_HTTP_PORT }, | ||||||
|  |     }) | ||||||
|  | 
 | ||||||
|  |     await mailServer.listen(); | ||||||
|  | 
 | ||||||
|  |     await utils.startVault(browser, testInfo, { | ||||||
|  |         SMTP_HOST: process.env.MAILDEV_HOST, | ||||||
|  |         SMTP_FROM: process.env.PW_SMTP_FROM, | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     mail1Buffer = mailServer.buffer(users.user1.email); | ||||||
|  |     mail2Buffer = mailServer.buffer(users.user2.email); | ||||||
|  |     mail3Buffer = mailServer.buffer(users.user3.email); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test.afterAll('Teardown', async ({}, testInfo: TestInfo) => { | ||||||
|  |     utils.stopVault(testInfo); | ||||||
|  |     [mail1Buffer, mail2Buffer, mail3Buffer, mailServer].map((m) => m?.close()); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test('Create user3', async ({ page }) => { | ||||||
|  |     await createAccount(test, page, users.user3, mail3Buffer); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test('Invite users', async ({ page }) => { | ||||||
|  |     await createAccount(test, page, users.user1, mail1Buffer); | ||||||
|  | 
 | ||||||
|  |     await orgs.create(test, page, 'Test'); | ||||||
|  |     await orgs.members(test, page, 'Test'); | ||||||
|  |     await orgs.invite(test, page, 'Test', users.user2.email); | ||||||
|  |     await orgs.invite(test, page, 'Test', users.user3.email, { | ||||||
|  |         navigate: false, | ||||||
|  |     }); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test('invited with new account', async ({ page }) => { | ||||||
|  |     const invited = await mail2Buffer.expect((mail) => mail.subject === 'Join Test'); | ||||||
|  | 
 | ||||||
|  |     await test.step('Create account', async () => { | ||||||
|  |         await page.setContent(invited.html); | ||||||
|  |         const link = await page.getByTestId('invite').getAttribute('href'); | ||||||
|  |         await page.goto(link); | ||||||
|  |         await expect(page).toHaveTitle(/Create account | Vaultwarden Web/); | ||||||
|  | 
 | ||||||
|  |         //await page.getByLabel('Name').fill(users.user2.name);
 | ||||||
|  |         await page.getByLabel('New master password (required)', { exact: true }).fill(users.user2.password); | ||||||
|  |         await page.getByLabel('Confirm new master password (').fill(users.user2.password); | ||||||
|  |         await page.getByRole('button', { name: 'Create account' }).click(); | ||||||
|  |         await utils.checkNotification(page, 'Your new account has been created'); | ||||||
|  | 
 | ||||||
|  |         // Redirected to the vault
 | ||||||
|  |         await expect(page).toHaveTitle('Vaults | Vaultwarden Web'); | ||||||
|  |         await utils.checkNotification(page, 'You have been logged in!'); | ||||||
|  |         await utils.checkNotification(page, 'Invitation accepted'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     await test.step('Check mails', async () => { | ||||||
|  |         await mail2Buffer.expect((m) => m.subject === 'Welcome'); | ||||||
|  |         await mail2Buffer.expect((m) => m.subject === 'New Device Logged In From Firefox'); | ||||||
|  |         await mail1Buffer.expect((m) => m.subject.includes('Invitation to Test accepted')); | ||||||
|  |     }); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test('invited with existing account', async ({ page }) => { | ||||||
|  |     const invited = await mail3Buffer.expect((mail) => mail.subject === 'Join Test'); | ||||||
|  | 
 | ||||||
|  |     await page.setContent(invited.html); | ||||||
|  |     const link = await page.getByTestId('invite').getAttribute('href'); | ||||||
|  | 
 | ||||||
|  |     await page.goto(link); | ||||||
|  | 
 | ||||||
|  |     // We should be on login page with email prefilled
 | ||||||
|  |     await expect(page).toHaveTitle(/Vaultwarden Web/); | ||||||
|  |     await page.getByRole('button', { name: 'Continue' }).click(); | ||||||
|  | 
 | ||||||
|  |     // Unlock page
 | ||||||
|  |     await page.getByLabel('Master password').fill(users.user3.password); | ||||||
|  |     await page.getByRole('button', { name: 'Log in with master password' }).click(); | ||||||
|  | 
 | ||||||
|  |     // We are now in the default vault page
 | ||||||
|  |     await expect(page).toHaveTitle(/Vaultwarden Web/); | ||||||
|  |     await utils.checkNotification(page, 'Invitation accepted'); | ||||||
|  | 
 | ||||||
|  |     await mail3Buffer.expect((m) => m.subject === 'New Device Logged In From Firefox'); | ||||||
|  |     await mail1Buffer.expect((m) => m.subject.includes('Invitation to Test accepted')); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test('Confirm invited user', async ({ page }) => { | ||||||
|  |     await logUser(test, page, users.user1, mail1Buffer); | ||||||
|  | 
 | ||||||
|  |     await orgs.members(test, page, 'Test'); | ||||||
|  |     await orgs.confirm(test, page, 'Test', users.user2.email); | ||||||
|  | 
 | ||||||
|  |     await mail2Buffer.expect((m) => m.subject.includes('Invitation to Test confirmed')); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test('Organization is visible', async ({ page }) => { | ||||||
|  |     await logUser(test, page, users.user2, mail2Buffer); | ||||||
|  |     await page.getByRole('button', { name: 'vault: Test', exact: true }).click(); | ||||||
|  |     await expect(page.getByLabel('Filter: Default collection')).toBeVisible(); | ||||||
|  | }); | ||||||
							
								
								
									
										54
									
								
								playwright/tests/organization.spec.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								playwright/tests/organization.spec.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,54 @@ | ||||||
|  | import { test, expect, type TestInfo } from '@playwright/test'; | ||||||
|  | import { MailDev } from 'maildev'; | ||||||
|  | 
 | ||||||
|  | import * as utils from "../global-utils"; | ||||||
|  | import * as orgs from './setups/orgs'; | ||||||
|  | import { createAccount, logUser } from './setups/user'; | ||||||
|  | 
 | ||||||
|  | let users = utils.loadEnv(); | ||||||
|  | 
 | ||||||
|  | test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { | ||||||
|  |     await utils.startVault(browser, testInfo); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test.afterAll('Teardown', async ({}) => { | ||||||
|  |     utils.stopVault(); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test('Invite', async ({ page }) => { | ||||||
|  |     await createAccount(test, page, users.user3); | ||||||
|  |     await createAccount(test, page, users.user1); | ||||||
|  | 
 | ||||||
|  |     await orgs.create(test, page, 'New organisation'); | ||||||
|  |     await orgs.members(test, page, 'New organisation'); | ||||||
|  | 
 | ||||||
|  |     await test.step('missing user2', async () => { | ||||||
|  |         await orgs.invite(test, page, 'New organisation', users.user2.email); | ||||||
|  |         await expect(page.getByRole('row', { name: users.user2.email })).toHaveText(/Invited/); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     await test.step('existing user3', async () => { | ||||||
|  |         await orgs.invite(test, page, 'New organisation', users.user3.email); | ||||||
|  |         await expect(page.getByRole('row', { name: users.user3.email })).toHaveText(/Needs confirmation/); | ||||||
|  |         await orgs.confirm(test, page, 'New organisation', users.user3.email); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     await test.step('confirm user2', async () => { | ||||||
|  |         await createAccount(test, page, users.user2); | ||||||
|  |         await logUser(test, page, users.user1); | ||||||
|  |         await orgs.members(test, page, 'New organisation'); | ||||||
|  |         await orgs.confirm(test, page, 'New organisation', users.user2.email); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     await test.step('Org visible user2  ', async () => { | ||||||
|  |         await logUser(test, page, users.user2); | ||||||
|  |         await page.getByRole('button', { name: 'vault: New organisation', exact: true }).click(); | ||||||
|  |         await expect(page.getByLabel('Filter: Default collection')).toBeVisible(); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     await test.step('Org visible user3  ', async () => { | ||||||
|  |         await logUser(test, page, users.user3); | ||||||
|  |         await page.getByRole('button', { name: 'vault: New organisation', exact: true }).click(); | ||||||
|  |         await expect(page.getByLabel('Filter: Default collection')).toBeVisible(); | ||||||
|  |     }); | ||||||
|  | }); | ||||||
							
								
								
									
										92
									
								
								playwright/tests/setups/2fa.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								playwright/tests/setups/2fa.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,92 @@ | ||||||
|  | import { expect, type Page, Test } from '@playwright/test'; | ||||||
|  | import { type MailBuffer } from 'maildev'; | ||||||
|  | import * as OTPAuth from "otpauth"; | ||||||
|  | 
 | ||||||
|  | import * as utils from '../../global-utils'; | ||||||
|  | 
 | ||||||
|  | export async function activateTOTP(test: Test, page: Page, user: { name: string, password: string }): OTPAuth.TOTP { | ||||||
|  |     return await test.step('Activate TOTP 2FA', async () => { | ||||||
|  |         await page.getByRole('button', { name: user.name }).click(); | ||||||
|  |         await page.getByRole('menuitem', { name: 'Account settings' }).click(); | ||||||
|  |         await page.getByRole('link', { name: 'Security' }).click(); | ||||||
|  |         await page.getByRole('link', { name: 'Two-step login' }).click(); | ||||||
|  |         await page.locator('bit-item').filter({ hasText: /Authenticator app/ }).getByRole('button').click(); | ||||||
|  |         await page.getByLabel('Master password (required)').fill(user.password); | ||||||
|  |         await page.getByRole('button', { name: 'Continue' }).click(); | ||||||
|  | 
 | ||||||
|  |         const secret = await page.getByLabel('Key').innerText(); | ||||||
|  |         let totp = new OTPAuth.TOTP({ secret, period: 30 }); | ||||||
|  | 
 | ||||||
|  |         await page.getByLabel(/Verification code/).fill(totp.generate()); | ||||||
|  |         await page.getByRole('button', { name: 'Turn on' }).click(); | ||||||
|  |         await page.getByRole('heading', { name: 'Turned on', exact: true }); | ||||||
|  |         await page.getByLabel('Close').click(); | ||||||
|  | 
 | ||||||
|  |         return totp; | ||||||
|  |     }) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export async function disableTOTP(test: Test, page: Page, user: { password: string }) { | ||||||
|  |     await test.step('Disable TOTP 2FA', async () => { | ||||||
|  |         await page.getByRole('button', { name: 'Test' }).click(); | ||||||
|  |         await page.getByRole('menuitem', { name: 'Account settings' }).click(); | ||||||
|  |         await page.getByRole('link', { name: 'Security' }).click(); | ||||||
|  |         await page.getByRole('link', { name: 'Two-step login' }).click(); | ||||||
|  |         await page.locator('bit-item').filter({ hasText: /Authenticator app/ }).getByRole('button').click(); | ||||||
|  |         await page.getByLabel('Master password (required)').click(); | ||||||
|  |         await page.getByLabel('Master password (required)').fill(user.password); | ||||||
|  |         await page.getByRole('button', { name: 'Continue' }).click(); | ||||||
|  |         await page.getByRole('button', { name: 'Turn off' }).click(); | ||||||
|  |         await page.getByRole('button', { name: 'Yes' }).click(); | ||||||
|  |         await utils.checkNotification(page, 'Two-step login provider turned off'); | ||||||
|  |     }); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export async function activateEmail(test: Test, page: Page, user: { name: string, password: string }, mailBuffer: MailBuffer) { | ||||||
|  |     await test.step('Activate Email 2FA', async () => { | ||||||
|  |         await page.getByRole('button', { name: user.name }).click(); | ||||||
|  |         await page.getByRole('menuitem', { name: 'Account settings' }).click(); | ||||||
|  |         await page.getByRole('link', { name: 'Security' }).click(); | ||||||
|  |         await page.getByRole('link', { name: 'Two-step login' }).click(); | ||||||
|  |         await page.locator('bit-item').filter({ hasText: 'Email Email Enter a code sent' }).getByRole('button').click(); | ||||||
|  |         await page.getByLabel('Master password (required)').fill(user.password); | ||||||
|  |         await page.getByRole('button', { name: 'Continue' }).click(); | ||||||
|  |         await page.getByRole('button', { name: 'Send email' }).click(); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     let code = await retrieveEmailCode(test, page, mailBuffer); | ||||||
|  | 
 | ||||||
|  |     await test.step('input code', async () => { | ||||||
|  |         await page.getByLabel('2. Enter the resulting 6').fill(code); | ||||||
|  |         await page.getByRole('button', { name: 'Turn on' }).click(); | ||||||
|  |         await page.getByRole('heading', { name: 'Turned on', exact: true }); | ||||||
|  |     }); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export async function retrieveEmailCode(test: Test, page: Page, mailBuffer: MailBuffer): string { | ||||||
|  |     return await test.step('retrieve code', async () => { | ||||||
|  |         const codeMail = await mailBuffer.expect((mail) => mail.subject.includes("Login Verification Code")); | ||||||
|  |         const page2 = await page.context().newPage(); | ||||||
|  |         await page2.setContent(codeMail.html); | ||||||
|  |         const code = await page2.getByTestId("2fa").innerText(); | ||||||
|  |         await page2.close(); | ||||||
|  |         return code; | ||||||
|  |     }); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export async function disableEmail(test: Test, page: Page, user: { password: string }) { | ||||||
|  |     await test.step('Disable Email 2FA', async () => { | ||||||
|  |         await page.getByRole('button', { name: 'Test' }).click(); | ||||||
|  |         await page.getByRole('menuitem', { name: 'Account settings' }).click(); | ||||||
|  |         await page.getByRole('link', { name: 'Security' }).click(); | ||||||
|  |         await page.getByRole('link', { name: 'Two-step login' }).click(); | ||||||
|  |         await page.locator('bit-item').filter({ hasText: 'Email' }).getByRole('button').click(); | ||||||
|  |         await page.getByLabel('Master password (required)').click(); | ||||||
|  |         await page.getByLabel('Master password (required)').fill(user.password); | ||||||
|  |         await page.getByRole('button', { name: 'Continue' }).click(); | ||||||
|  |         await page.getByRole('button', { name: 'Turn off' }).click(); | ||||||
|  |         await page.getByRole('button', { name: 'Yes' }).click(); | ||||||
|  | 
 | ||||||
|  |         await utils.checkNotification(page, 'Two-step login provider turned off'); | ||||||
|  |     }); | ||||||
|  | } | ||||||
							
								
								
									
										7
									
								
								playwright/tests/setups/db-setup.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								playwright/tests/setups/db-setup.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,7 @@ | ||||||
|  | import { test } from './db-test'; | ||||||
|  | 
 | ||||||
|  | const utils = require('../../global-utils'); | ||||||
|  | 
 | ||||||
|  | test('DB start', async ({ serviceName }) => { | ||||||
|  | 	utils.startComposeService(serviceName); | ||||||
|  | }); | ||||||
							
								
								
									
										11
									
								
								playwright/tests/setups/db-teardown.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								playwright/tests/setups/db-teardown.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,11 @@ | ||||||
|  | import { test } from './db-test'; | ||||||
|  | 
 | ||||||
|  | const utils = require('../../global-utils'); | ||||||
|  | 
 | ||||||
|  | utils.loadEnv(); | ||||||
|  | 
 | ||||||
|  | test('DB teardown ?', async ({ serviceName }) => { | ||||||
|  |     if( process.env.PW_KEEP_SERVICE_RUNNNING !== "true" ) { | ||||||
|  |         utils.stopComposeService(serviceName); | ||||||
|  |     } | ||||||
|  | }); | ||||||
							
								
								
									
										9
									
								
								playwright/tests/setups/db-test.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								playwright/tests/setups/db-test.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,9 @@ | ||||||
|  | import { test as base } from '@playwright/test'; | ||||||
|  | 
 | ||||||
|  | export type TestOptions = { | ||||||
|  |   serviceName: string; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export const test = base.extend<TestOptions>({ | ||||||
|  |   serviceName: ['', { option: true }], | ||||||
|  | }); | ||||||
							
								
								
									
										77
									
								
								playwright/tests/setups/orgs.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										77
									
								
								playwright/tests/setups/orgs.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,77 @@ | ||||||
|  | import { expect, type Browser,Page } from '@playwright/test'; | ||||||
|  | 
 | ||||||
|  | import * as utils from '../../global-utils'; | ||||||
|  | 
 | ||||||
|  | export async function create(test, page: Page, name: string) { | ||||||
|  |     await test.step('Create Org', async () => { | ||||||
|  |         await page.locator('a').filter({ hasText: 'Password Manager' }).first().click(); | ||||||
|  |         await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible(); | ||||||
|  |         await page.getByRole('link', { name: 'New organisation' }).click(); | ||||||
|  |         await page.getByLabel('Organisation name (required)').fill(name); | ||||||
|  |         await page.getByRole('button', { name: 'Submit' }).click(); | ||||||
|  | 
 | ||||||
|  |         await utils.checkNotification(page, 'Organisation created'); | ||||||
|  |     }); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export async function policies(test, page: Page, name: string) { | ||||||
|  |     await test.step(`Navigate to ${name} policies`, async () => { | ||||||
|  |         await page.locator('a').filter({ hasText: 'Admin Console' }).first().click(); | ||||||
|  |         await page.locator('org-switcher').getByLabel(/Toggle collapse/).click(); | ||||||
|  |         await page.locator('org-switcher').getByRole('link', { name: `${name}` }).first().click(); | ||||||
|  |         await expect(page.getByRole('heading', { name: `${name} collections` })).toBeVisible(); | ||||||
|  |         await page.getByRole('button', { name: 'Toggle collapse Settings' }).click(); | ||||||
|  |         await page.getByRole('link', { name: 'Policies' }).click(); | ||||||
|  |         await expect(page.getByRole('heading', { name: 'Policies' })).toBeVisible(); | ||||||
|  |     }); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export async function members(test, page: Page, name: string) { | ||||||
|  |     await test.step(`Navigate to ${name} members`, async () => { | ||||||
|  |         await page.locator('a').filter({ hasText: 'Admin Console' }).first().click(); | ||||||
|  |         await page.locator('org-switcher').getByLabel(/Toggle collapse/).click(); | ||||||
|  |         await page.locator('org-switcher').getByRole('link', { name: `${name}` }).first().click(); | ||||||
|  |         await expect(page.getByRole('heading', { name: `${name} collections` })).toBeVisible(); | ||||||
|  |         await page.locator('div').filter({ hasText: 'Members' }).nth(2).click(); | ||||||
|  |         await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible(); | ||||||
|  |         await expect(page.getByRole('cell', { name: 'All' })).toBeVisible(); | ||||||
|  |     }); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export async function invite(test, page: Page, name: string, email: string) { | ||||||
|  |     await test.step(`Invite ${email}`, async () => { | ||||||
|  |         await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible(); | ||||||
|  |         await page.getByRole('button', { name: 'Invite member' }).click(); | ||||||
|  |         await page.getByLabel('Email (required)').fill(email); | ||||||
|  |         await page.getByRole('tab', { name: 'Collections' }).click(); | ||||||
|  |         await page.getByRole('combobox', { name: 'Permission' }).click(); | ||||||
|  |         await page.getByText('Edit items', { exact: true }).click(); | ||||||
|  |         await page.getByLabel('Select collections').click(); | ||||||
|  |         await page.getByText('Default collection').click(); | ||||||
|  |         await page.getByRole('cell', { name: 'Collection', exact: true }).click(); | ||||||
|  |         await page.getByRole('button', { name: 'Save' }).click(); | ||||||
|  |         await utils.checkNotification(page, 'User(s) invited'); | ||||||
|  |     }); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export async function confirm(test, page: Page, name: string, user_email: string) { | ||||||
|  |     await test.step(`Confirm ${user_email}`, async () => { | ||||||
|  |         await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible(); | ||||||
|  |         await page.getByRole('row').filter({hasText: user_email}).getByLabel('Options').click(); | ||||||
|  |         await page.getByRole('menuitem', { name: 'Confirm' }).click(); | ||||||
|  |         await expect(page.getByRole('heading', { name: 'Confirm user' })).toBeVisible(); | ||||||
|  |         await page.getByRole('button', { name: 'Confirm' }).click(); | ||||||
|  |         await utils.checkNotification(page, 'confirmed'); | ||||||
|  |     }); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export async function revoke(test, page: Page, name: string, user_email: string) { | ||||||
|  |     await test.step(`Revoke ${user_email}`, async () => { | ||||||
|  |         await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible(); | ||||||
|  |         await page.getByRole('row').filter({hasText: user_email}).getByLabel('Options').click(); | ||||||
|  |         await page.getByRole('menuitem', { name: 'Revoke access' }).click(); | ||||||
|  |         await expect(page.getByRole('heading', { name: 'Revoke access' })).toBeVisible(); | ||||||
|  |         await page.getByRole('button', { name: 'Revoke access' }).click(); | ||||||
|  |         await utils.checkNotification(page, 'Revoked organisation access'); | ||||||
|  |     }); | ||||||
|  | } | ||||||
							
								
								
									
										18
									
								
								playwright/tests/setups/sso-setup.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								playwright/tests/setups/sso-setup.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,18 @@ | ||||||
|  | import { test, expect, type TestInfo } from '@playwright/test'; | ||||||
|  | 
 | ||||||
|  | const { exec } = require('node:child_process'); | ||||||
|  | const utils = require('../../global-utils'); | ||||||
|  | 
 | ||||||
|  | utils.loadEnv(); | ||||||
|  | 
 | ||||||
|  | test.beforeAll('Setup', async () => { | ||||||
|  |     console.log("Starting Keycloak"); | ||||||
|  |     exec(`docker compose --profile keycloak --env-file test.env up`); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test('Keycloak is up', async ({ page }) => { | ||||||
|  |     await utils.waitFor(process.env.SSO_AUTHORITY, page.context().browser()); | ||||||
|  |     // Dummy authority is created at the end of the setup
 | ||||||
|  |     await utils.waitFor(process.env.DUMMY_AUTHORITY, page.context().browser()); | ||||||
|  |     console.log(`Keycloak running on: ${process.env.SSO_AUTHORITY}`); | ||||||
|  | }); | ||||||
							
								
								
									
										15
									
								
								playwright/tests/setups/sso-teardown.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								playwright/tests/setups/sso-teardown.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,15 @@ | ||||||
|  | import { test, type FullConfig } from '@playwright/test'; | ||||||
|  | 
 | ||||||
|  | const { execSync } = require('node:child_process'); | ||||||
|  | const utils = require('../../global-utils'); | ||||||
|  | 
 | ||||||
|  | utils.loadEnv(); | ||||||
|  | 
 | ||||||
|  | test('Keycloak teardown', async () => { | ||||||
|  |     if( process.env.PW_KEEP_SERVICE_RUNNNING === "true" ) { | ||||||
|  |         console.log("Keep Keycloak running"); | ||||||
|  |     } else { | ||||||
|  |         console.log("Keycloak stopping"); | ||||||
|  |         execSync(`docker compose --profile keycloak --env-file test.env stop Keycloak`); | ||||||
|  |     } | ||||||
|  | }); | ||||||
							
								
								
									
										138
									
								
								playwright/tests/setups/sso.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										138
									
								
								playwright/tests/setups/sso.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,138 @@ | ||||||
|  | import { expect, type Page, Test } from '@playwright/test'; | ||||||
|  | import { type MailBuffer, MailServer } from 'maildev'; | ||||||
|  | import * as OTPAuth from "otpauth"; | ||||||
|  | 
 | ||||||
|  | import * as utils from '../../global-utils'; | ||||||
|  | import { retrieveEmailCode } from './2fa'; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * If a MailBuffer is passed it will be used and consume the expected emails | ||||||
|  |  */ | ||||||
|  | export async function logNewUser( | ||||||
|  |     test: Test, | ||||||
|  |     page: Page, | ||||||
|  |     user: { email: string, name: string, password: string }, | ||||||
|  |     options: { mailBuffer?: MailBuffer, override?: boolean } = {} | ||||||
|  | ) { | ||||||
|  |     await test.step(`Create user ${user.name}`, async () => { | ||||||
|  |         await page.context().clearCookies(); | ||||||
|  | 
 | ||||||
|  |         await test.step('Landing page', async () => { | ||||||
|  |             await utils.cleanLanding(page); | ||||||
|  | 
 | ||||||
|  |             if( options.override ) { | ||||||
|  |                 await page.getByRole('button', { name: 'Continue' }).click(); | ||||||
|  |             } else { | ||||||
|  |                 await page.getByLabel(/Email address/).fill(user.email); | ||||||
|  |                 await page.getByRole('button', { name: /Use single sign-on/ }).click(); | ||||||
|  |             } | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|  |         await test.step('Keycloak login', async () => { | ||||||
|  |             await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible(); | ||||||
|  |             await page.getByLabel(/Username/).fill(user.name); | ||||||
|  |             await page.getByLabel('Password', { exact: true }).fill(user.password); | ||||||
|  |             await page.getByRole('button', { name: 'Sign In' }).click(); | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|  |         await test.step('Create Vault account', async () => { | ||||||
|  |             await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible(); | ||||||
|  |             await page.getByLabel('New master password (required)', { exact: true }).fill(user.password); | ||||||
|  |             await page.getByLabel('Confirm new master password (').fill(user.password); | ||||||
|  |             await page.getByRole('button', { name: 'Create account' }).click(); | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|  |         await test.step('Default vault page', async () => { | ||||||
|  |             await expect(page).toHaveTitle(/Vaultwarden Web/); | ||||||
|  |             await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible(); | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|  |         await utils.checkNotification(page, 'Account successfully created!'); | ||||||
|  |         await utils.checkNotification(page, 'Invitation accepted'); | ||||||
|  | 
 | ||||||
|  |         if( options.mailBuffer ){ | ||||||
|  |             let mailBuffer = options.mailBuffer; | ||||||
|  |             await test.step('Check emails', async () => { | ||||||
|  |                 await mailBuffer.expect((m) => m.subject === "Welcome"); | ||||||
|  |                 await mailBuffer.expect((m) => m.subject.includes("New Device Logged")); | ||||||
|  |             }); | ||||||
|  |         } | ||||||
|  |     }); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * If a MailBuffer is passed it will be used and consume the expected emails | ||||||
|  |  */ | ||||||
|  | export async function logUser( | ||||||
|  |     test: Test, | ||||||
|  |     page: Page, | ||||||
|  |     user: { email: string, password: string }, | ||||||
|  |     options: { | ||||||
|  |         mailBuffer ?: MailBuffer, | ||||||
|  |         override?: boolean, | ||||||
|  |         totp?: OTPAuth.TOTP, | ||||||
|  |         mail2fa?: boolean, | ||||||
|  |     } = {} | ||||||
|  | ) { | ||||||
|  |     let mailBuffer = options.mailBuffer; | ||||||
|  | 
 | ||||||
|  |     await test.step(`Log user ${user.email}`, async () => { | ||||||
|  |         await page.context().clearCookies(); | ||||||
|  | 
 | ||||||
|  |         await test.step('Landing page', async () => { | ||||||
|  |             await utils.cleanLanding(page); | ||||||
|  | 
 | ||||||
|  |             if( options.override ) { | ||||||
|  |                 await page.getByRole('button', { name: 'Continue' }).click(); | ||||||
|  |             } else { | ||||||
|  |                 await page.getByLabel(/Email address/).fill(user.email); | ||||||
|  |                 await page.getByRole('button', { name: /Use single sign-on/ }).click(); | ||||||
|  |             } | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|  |         await test.step('Keycloak login', async () => { | ||||||
|  |             await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible(); | ||||||
|  |             await page.getByLabel(/Username/).fill(user.name); | ||||||
|  |             await page.getByLabel('Password', { exact: true }).fill(user.password); | ||||||
|  |             await page.getByRole('button', { name: 'Sign In' }).click(); | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|  |         if( options.totp || options.mail2fa ){ | ||||||
|  |             let code; | ||||||
|  | 
 | ||||||
|  |             await test.step('2FA check', async () => { | ||||||
|  |                 await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible(); | ||||||
|  | 
 | ||||||
|  |                 if( options.totp ) { | ||||||
|  |                     const totp = options.totp; | ||||||
|  |                     let timestamp = Date.now(); // Needed to use the next token
 | ||||||
|  |                     timestamp = timestamp + (totp.period - (Math.floor(timestamp / 1000) % totp.period) + 1) * 1000; | ||||||
|  |                     code = totp.generate({timestamp}); | ||||||
|  |                 } else if( options.mail2fa ){ | ||||||
|  |                     code = await retrieveEmailCode(test, page, mailBuffer); | ||||||
|  |                 } | ||||||
|  | 
 | ||||||
|  |                 await page.getByLabel(/Verification code/).fill(code); | ||||||
|  |                 await page.getByRole('button', { name: 'Continue' }).click(); | ||||||
|  |             }); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         await test.step('Unlock vault', async () => { | ||||||
|  |             await expect(page).toHaveTitle('Vaultwarden Web'); | ||||||
|  |             await expect(page.getByRole('heading', { name: 'Your vault is locked' })).toBeVisible(); | ||||||
|  |             await page.getByLabel('Master password').fill(user.password); | ||||||
|  |             await page.getByRole('button', { name: 'Unlock' }).click(); | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|  |         await test.step('Default vault page', async () => { | ||||||
|  |             await expect(page).toHaveTitle(/Vaultwarden Web/); | ||||||
|  |             await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible(); | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|  |         if( mailBuffer ){ | ||||||
|  |             await test.step('Check email', async () => { | ||||||
|  |                 await mailBuffer.expect((m) => m.subject.includes("New Device Logged")); | ||||||
|  |             }); | ||||||
|  |         } | ||||||
|  |     }); | ||||||
|  | } | ||||||
							
								
								
									
										55
									
								
								playwright/tests/setups/user.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								playwright/tests/setups/user.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,55 @@ | ||||||
|  | import { expect, type Browser, Page } from '@playwright/test'; | ||||||
|  | 
 | ||||||
|  | import { type MailBuffer } from 'maildev'; | ||||||
|  | 
 | ||||||
|  | import * as utils from '../../global-utils'; | ||||||
|  | 
 | ||||||
|  | export async function createAccount(test, page: Page, user: { email: string, name: string, password: string }, mailBuffer?: MailBuffer) { | ||||||
|  |     await test.step(`Create user ${user.name}`, async () => { | ||||||
|  |         await utils.cleanLanding(page); | ||||||
|  | 
 | ||||||
|  |         await page.getByRole('link', { name: 'Create account' }).click(); | ||||||
|  | 
 | ||||||
|  |         // Back to Vault create account
 | ||||||
|  |         await expect(page).toHaveTitle(/Create account | Vaultwarden Web/); | ||||||
|  |         await page.getByLabel(/Email address/).fill(user.email); | ||||||
|  |         await page.getByLabel('Name').fill(user.name); | ||||||
|  |         await page.getByRole('button', { name: 'Continue' }).click(); | ||||||
|  | 
 | ||||||
|  |         // Vault finish Creation
 | ||||||
|  |         await page.getByLabel('New master password (required)', { exact: true }).fill(user.password); | ||||||
|  |         await page.getByLabel('Confirm new master password (').fill(user.password); | ||||||
|  |         await page.getByRole('button', { name: 'Create account' }).click(); | ||||||
|  | 
 | ||||||
|  |         await utils.checkNotification(page, 'Your new account has been created') | ||||||
|  | 
 | ||||||
|  |         // We are now in the default vault page
 | ||||||
|  |         await expect(page).toHaveTitle('Vaults | Vaultwarden Web'); | ||||||
|  |         await utils.checkNotification(page, 'You have been logged in!'); | ||||||
|  | 
 | ||||||
|  |         if( mailBuffer ){ | ||||||
|  |             await mailBuffer.expect((m) => m.subject === "Welcome"); | ||||||
|  |             await mailBuffer.expect((m) => m.subject === "New Device Logged In From Firefox"); | ||||||
|  |         } | ||||||
|  |     }); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export async function logUser(test, page: Page, user: { email: string, password: string }, mailBuffer?: MailBuffer) { | ||||||
|  |     await test.step(`Log user ${user.email}`, async () => { | ||||||
|  |         await utils.cleanLanding(page); | ||||||
|  | 
 | ||||||
|  |         await page.getByLabel(/Email address/).fill(user.email); | ||||||
|  |         await page.getByRole('button', { name: 'Continue' }).click(); | ||||||
|  | 
 | ||||||
|  |         // Unlock page
 | ||||||
|  |         await page.getByLabel('Master password').fill(user.password); | ||||||
|  |         await page.getByRole('button', { name: 'Log in with master password' }).click(); | ||||||
|  | 
 | ||||||
|  |         // We are now in the default vault page
 | ||||||
|  |         await expect(page).toHaveTitle(/Vaultwarden Web/); | ||||||
|  | 
 | ||||||
|  |         if( mailBuffer ){ | ||||||
|  |             await mailBuffer.expect((m) => m.subject === "New Device Logged In From Firefox"); | ||||||
|  |         } | ||||||
|  |     }); | ||||||
|  | } | ||||||
							
								
								
									
										53
									
								
								playwright/tests/sso_login.smtp.spec.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								playwright/tests/sso_login.smtp.spec.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,53 @@ | ||||||
|  | import { test, expect, type TestInfo } from '@playwright/test'; | ||||||
|  | import { MailDev } from 'maildev'; | ||||||
|  | 
 | ||||||
|  | import { logNewUser, logUser } from './setups/sso'; | ||||||
|  | import { activateEmail, disableEmail } from './setups/2fa'; | ||||||
|  | import * as utils from "../global-utils"; | ||||||
|  | 
 | ||||||
|  | let users = utils.loadEnv(); | ||||||
|  | 
 | ||||||
|  | let mailserver; | ||||||
|  | 
 | ||||||
|  | test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { | ||||||
|  |     mailserver = new MailDev({ | ||||||
|  |         port: process.env.MAILDEV_SMTP_PORT, | ||||||
|  |         web: { port: process.env.MAILDEV_HTTP_PORT }, | ||||||
|  |     }) | ||||||
|  | 
 | ||||||
|  |     await mailserver.listen(); | ||||||
|  | 
 | ||||||
|  |     await utils.startVault(browser, testInfo, { | ||||||
|  |         SSO_ENABLED: true, | ||||||
|  |         SSO_ONLY: false, | ||||||
|  |         SMTP_HOST: process.env.MAILDEV_HOST, | ||||||
|  |         SMTP_FROM: process.env.PW_SMTP_FROM, | ||||||
|  |     }); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test.afterAll('Teardown', async ({}) => { | ||||||
|  |     utils.stopVault(); | ||||||
|  |     if( mailserver ){ | ||||||
|  |         await mailserver.close(); | ||||||
|  |     } | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test('Create and activate 2FA', async ({ page }) => { | ||||||
|  |     const mailBuffer = mailserver.buffer(users.user1.email); | ||||||
|  | 
 | ||||||
|  |     await logNewUser(test, page, users.user1, {mailBuffer: mailBuffer}); | ||||||
|  | 
 | ||||||
|  |     await activateEmail(test, page, users.user1, mailBuffer); | ||||||
|  | 
 | ||||||
|  |     mailBuffer.close(); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test('Log and disable', async ({ page }) => { | ||||||
|  |     const mailBuffer = mailserver.buffer(users.user1.email); | ||||||
|  | 
 | ||||||
|  |     await logUser(test, page, users.user1, {mailBuffer: mailBuffer, mail2fa: true}); | ||||||
|  | 
 | ||||||
|  |     await disableEmail(test, page, users.user1); | ||||||
|  | 
 | ||||||
|  |     mailBuffer.close(); | ||||||
|  | }); | ||||||
							
								
								
									
										94
									
								
								playwright/tests/sso_login.spec.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										94
									
								
								playwright/tests/sso_login.spec.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,94 @@ | ||||||
|  | import { test, expect, type TestInfo } from '@playwright/test'; | ||||||
|  | 
 | ||||||
|  | import { logNewUser, logUser } from './setups/sso'; | ||||||
|  | import { activateTOTP, disableTOTP } from './setups/2fa'; | ||||||
|  | import * as utils from "../global-utils"; | ||||||
|  | 
 | ||||||
|  | let users = utils.loadEnv(); | ||||||
|  | 
 | ||||||
|  | test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { | ||||||
|  |     await utils.startVault(browser, testInfo, { | ||||||
|  |         SSO_ENABLED: true, | ||||||
|  |         SSO_ONLY: false | ||||||
|  |     }); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test.afterAll('Teardown', async ({}) => { | ||||||
|  |     utils.stopVault(); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test('Account creation using SSO', async ({ page }) => { | ||||||
|  |     // Landing page
 | ||||||
|  |     await logNewUser(test, page, users.user1); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test('SSO login', async ({ page }) => { | ||||||
|  |     await logUser(test, page, users.user1); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test('Non SSO login', async ({ page }) => { | ||||||
|  |     // Landing page
 | ||||||
|  |     await page.goto('/'); | ||||||
|  |     await page.getByLabel(/Email address/).fill(users.user1.email); | ||||||
|  |     await page.getByRole('button', { name: 'Continue' }).click(); | ||||||
|  | 
 | ||||||
|  |     // Unlock page
 | ||||||
|  |     await page.getByLabel('Master password').fill(users.user1.password); | ||||||
|  |     await page.getByRole('button', { name: 'Log in with master password' }).click(); | ||||||
|  | 
 | ||||||
|  |     // We are now in the default vault page
 | ||||||
|  |     await expect(page).toHaveTitle(/Vaultwarden Web/); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test('SSO login with TOTP 2fa', async ({ page }) => { | ||||||
|  |     await logUser(test, page, users.user1); | ||||||
|  | 
 | ||||||
|  |     let totp = await activateTOTP(test, page, users.user1); | ||||||
|  | 
 | ||||||
|  |     await logUser(test, page, users.user1, { totp }); | ||||||
|  | 
 | ||||||
|  |     await disableTOTP(test, page, users.user1); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test('Non SSO login impossible', async ({ page, browser }, testInfo: TestInfo) => { | ||||||
|  |     await utils.restartVault(page, testInfo, { | ||||||
|  |         SSO_ENABLED: true, | ||||||
|  |         SSO_ONLY: true | ||||||
|  |     }, false); | ||||||
|  | 
 | ||||||
|  |     // Landing page
 | ||||||
|  |     await page.goto('/'); | ||||||
|  |     await page.getByLabel(/Email address/).fill(users.user1.email); | ||||||
|  | 
 | ||||||
|  |     // Check that SSO login is available
 | ||||||
|  |     await expect(page.getByRole('button', { name: /Use single sign-on/ })).toHaveCount(1); | ||||||
|  | 
 | ||||||
|  |     await page.getByLabel(/Email address/).fill(users.user1.email); | ||||||
|  |     await page.getByRole('button', { name: 'Continue' }).click(); | ||||||
|  | 
 | ||||||
|  |     // Unlock page
 | ||||||
|  |     await page.getByLabel('Master password').fill(users.user1.password); | ||||||
|  |     await page.getByRole('button', { name: 'Log in with master password' }).click(); | ||||||
|  | 
 | ||||||
|  |     // An error should appear
 | ||||||
|  |     await page.getByLabel('SSO sign-in is required') | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | test('No SSO login', async ({ page }, testInfo: TestInfo) => { | ||||||
|  |     await utils.restartVault(page, testInfo, { | ||||||
|  |         SSO_ENABLED: false | ||||||
|  |     }, false); | ||||||
|  | 
 | ||||||
|  |     // Landing page
 | ||||||
|  |     await page.goto('/'); | ||||||
|  |     await page.getByLabel(/Email address/).fill(users.user1.email); | ||||||
|  | 
 | ||||||
|  |     // No SSO button (rely on a correct selector checked in previous test)
 | ||||||
|  |     await page.getByLabel('Master password'); | ||||||
|  |     await expect(page.getByRole('button', { name: /Use single sign-on/ })).toHaveCount(0); | ||||||
|  | 
 | ||||||
|  |     // Can continue to Master password
 | ||||||
|  |     await page.getByRole('button', { name: 'Continue' }).click(); | ||||||
|  |     await expect(page.getByRole('button', { name: /Log in with master password/ })).toHaveCount(1); | ||||||
|  | }); | ||||||
							
								
								
									
										121
									
								
								playwright/tests/sso_organization.smtp.spec.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										121
									
								
								playwright/tests/sso_organization.smtp.spec.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,121 @@ | ||||||
|  | import { test, expect, type TestInfo } from '@playwright/test'; | ||||||
|  | import { MailDev } from 'maildev'; | ||||||
|  | 
 | ||||||
|  | import * as utils from "../global-utils"; | ||||||
|  | import * as orgs from './setups/orgs'; | ||||||
|  | import { logNewUser, logUser } from './setups/sso'; | ||||||
|  | 
 | ||||||
|  | let users = utils.loadEnv(); | ||||||
|  | 
 | ||||||
|  | let mailServer, mail1Buffer, mail2Buffer, mail3Buffer; | ||||||
|  | 
 | ||||||
|  | test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { | ||||||
|  |     mailServer = new MailDev({ | ||||||
|  |         port: process.env.MAILDEV_SMTP_PORT, | ||||||
|  |         web: { port: process.env.MAILDEV_HTTP_PORT }, | ||||||
|  |     }) | ||||||
|  | 
 | ||||||
|  |     await mailServer.listen(); | ||||||
|  | 
 | ||||||
|  |     await utils.startVault(browser, testInfo, { | ||||||
|  |         SMTP_HOST: process.env.MAILDEV_HOST, | ||||||
|  |         SMTP_FROM: process.env.PW_SMTP_FROM, | ||||||
|  |         SSO_ENABLED: true, | ||||||
|  |         SSO_ONLY: true, | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     mail1Buffer = mailServer.buffer(users.user1.email); | ||||||
|  |     mail2Buffer = mailServer.buffer(users.user2.email); | ||||||
|  |     mail3Buffer = mailServer.buffer(users.user3.email); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test.afterAll('Teardown', async ({}) => { | ||||||
|  |     utils.stopVault(); | ||||||
|  |     [mail1Buffer, mail2Buffer, mail3Buffer, mailServer].map((m) => m?.close()); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test('Create user3', async ({ page }) => { | ||||||
|  |     await logNewUser(test, page, users.user3, { mailBuffer: mail3Buffer }); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test('Invite users', async ({ page }) => { | ||||||
|  |     await logNewUser(test, page, users.user1, { mailBuffer: mail1Buffer }); | ||||||
|  | 
 | ||||||
|  |     await orgs.create(test, page, '/Test'); | ||||||
|  |     await orgs.members(test, page, '/Test'); | ||||||
|  |     await orgs.invite(test, page, '/Test', users.user2.email); | ||||||
|  |     await orgs.invite(test, page, '/Test', users.user3.email); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test('invited with new account', async ({ page }) => { | ||||||
|  |     const link = await test.step('Extract email link', async () => { | ||||||
|  |         const invited = await mail2Buffer.expect((m) => m.subject === "Join /Test"); | ||||||
|  |         await page.setContent(invited.html); | ||||||
|  |         return await page.getByTestId("invite").getAttribute("href"); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     await test.step('Redirect to Keycloak', async () => { | ||||||
|  |         await page.goto(link); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     await test.step('Keycloak login', async () => { | ||||||
|  |         await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible(); | ||||||
|  |         await page.getByLabel(/Username/).fill(users.user2.name); | ||||||
|  |         await page.getByLabel('Password', { exact: true }).fill(users.user2.password); | ||||||
|  |         await page.getByRole('button', { name: 'Sign In' }).click(); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     await test.step('Create Vault account', async () => { | ||||||
|  |         await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible(); | ||||||
|  |         await page.getByLabel('New master password (required)', { exact: true }).fill(users.user2.password); | ||||||
|  |         await page.getByLabel('Confirm new master password (').fill(users.user2.password); | ||||||
|  |         await page.getByRole('button', { name: 'Create account' }).click(); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     await test.step('Default vault page', async () => { | ||||||
|  |         await expect(page).toHaveTitle(/Vaultwarden Web/); | ||||||
|  | 
 | ||||||
|  |         await utils.checkNotification(page, 'Account successfully created!'); | ||||||
|  |         await utils.checkNotification(page, 'Invitation accepted'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     await test.step('Check mails', async () => { | ||||||
|  |         await mail2Buffer.expect((m) => m.subject.includes("New Device Logged")); | ||||||
|  |         await mail1Buffer.expect((m) => m.subject === "Invitation to /Test accepted"); | ||||||
|  |     }); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test('invited with existing account', async ({ page }) => { | ||||||
|  |     const link = await test.step('Extract email link', async () => { | ||||||
|  |         const invited = await mail3Buffer.expect((m) => m.subject === "Join /Test"); | ||||||
|  |         await page.setContent(invited.html); | ||||||
|  |         return await page.getByTestId("invite").getAttribute("href"); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     await test.step('Redirect to Keycloak', async () => { | ||||||
|  |         await page.goto(link); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     await test.step('Keycloak login', async () => { | ||||||
|  |         await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible(); | ||||||
|  |         await page.getByLabel(/Username/).fill(users.user3.name); | ||||||
|  |         await page.getByLabel('Password', { exact: true }).fill(users.user3.password); | ||||||
|  |         await page.getByRole('button', { name: 'Sign In' }).click(); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     await test.step('Unlock vault', async () => { | ||||||
|  |         await expect(page).toHaveTitle('Vaultwarden Web'); | ||||||
|  |         await page.getByLabel('Master password').fill(users.user3.password); | ||||||
|  |         await page.getByRole('button', { name: 'Unlock' }).click(); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     await test.step('Default vault page', async () => { | ||||||
|  |         await expect(page).toHaveTitle(/Vaultwarden Web/); | ||||||
|  |         await utils.checkNotification(page, 'Invitation accepted'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     await test.step('Check mails', async () => { | ||||||
|  |         await mail3Buffer.expect((m) => m.subject.includes("New Device Logged")); | ||||||
|  |         await mail1Buffer.expect((m) => m.subject === "Invitation to /Test accepted"); | ||||||
|  |     }); | ||||||
|  | }); | ||||||
							
								
								
									
										76
									
								
								playwright/tests/sso_organization.spec.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								playwright/tests/sso_organization.spec.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -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(); | ||||||
|  |     }); | ||||||
|  | }); | ||||||
|  | @ -46,6 +46,7 @@ pub fn routes() -> Vec<Route> { | ||||||
|         invite_user, |         invite_user, | ||||||
|         logout, |         logout, | ||||||
|         delete_user, |         delete_user, | ||||||
|  |         delete_sso_user, | ||||||
|         deauth_user, |         deauth_user, | ||||||
|         disable_user, |         disable_user, | ||||||
|         enable_user, |         enable_user, | ||||||
|  | @ -239,6 +240,7 @@ struct AdminTemplateData { | ||||||
|     page_data: Option<Value>, |     page_data: Option<Value>, | ||||||
|     logged_in: bool, |     logged_in: bool, | ||||||
|     urlpath: String, |     urlpath: String, | ||||||
|  |     sso_enabled: bool, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| impl AdminTemplateData { | impl AdminTemplateData { | ||||||
|  | @ -248,6 +250,7 @@ impl AdminTemplateData { | ||||||
|             page_data: Some(page_data), |             page_data: Some(page_data), | ||||||
|             logged_in: true, |             logged_in: true, | ||||||
|             urlpath: CONFIG.domain_path(), |             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) |         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 { |     async fn _generate_invite(user: &User, conn: &mut DbConn) -> EmptyResult { | ||||||
|         if CONFIG.mail_enabled() { |         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> { | async fn get_users_json(_token: AdminToken, mut conn: DbConn) -> Json<Value> { | ||||||
|     let users = User::get_all(&mut conn).await; |     let users = User::get_all(&mut conn).await; | ||||||
|     let mut users_json = Vec::with_capacity(users.len()); |     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; |         let mut usr = u.to_json(&mut conn).await; | ||||||
|         usr["userEnabled"] = json!(u.enabled); |         usr["userEnabled"] = json!(u.enabled); | ||||||
|         usr["createdAt"] = json!(format_naive_datetime_local(&u.created_at, DT_FMT)); |         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>> { | async fn users_overview(_token: AdminToken, mut conn: DbConn) -> ApiResult<Html<String>> { | ||||||
|     let users = User::get_all(&mut conn).await; |     let users = User::get_all(&mut conn).await; | ||||||
|     let mut users_json = Vec::with_capacity(users.len()); |     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; |         let mut usr = u.to_json(&mut conn).await; | ||||||
|         usr["cipher_count"] = json!(Cipher::count_owned_by_user(&u.uuid, &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); |         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)), |             Some(dt) => json!(format_naive_datetime_local(&dt, DT_FMT)), | ||||||
|             None => json!("Never"), |             None => json!("Never"), | ||||||
|         }; |         }; | ||||||
|  | 
 | ||||||
|  |         usr["sso_identifier"] = json!(sso_u.map(|u| u.identifier.to_string()).unwrap_or(String::new())); | ||||||
|  | 
 | ||||||
|         users_json.push(usr); |         users_json.push(usr); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | @ -417,6 +423,27 @@ async fn delete_user(user_id: UserId, token: AdminToken, mut conn: DbConn) -> Em | ||||||
|     res |     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")] | #[post("/users/<user_id>/deauth", format = "application/json")] | ||||||
| async fn deauth_user(user_id: UserId, _token: AdminToken, mut conn: DbConn, nt: Notify<'_>) -> EmptyResult { | 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?; |     let mut user = get_user_or_404(&user_id, &mut conn).await?; | ||||||
|  |  | ||||||
|  | @ -7,9 +7,9 @@ use serde_json::Value; | ||||||
| 
 | 
 | ||||||
| use crate::{ | use crate::{ | ||||||
|     api::{ |     api::{ | ||||||
|         core::{log_user_event, two_factor::email}, |         core::{accept_org_invite, log_user_event, two_factor::email}, | ||||||
|         master_password_policy, register_push_device, unregister_push_device, AnonymousNotify, EmptyResult, JsonResult, |         master_password_policy, register_push_device, unregister_push_device, AnonymousNotify, ApiResult, EmptyResult, | ||||||
|         Notify, PasswordOrOtpData, UpdateType, |         JsonResult, Notify, PasswordOrOtpData, UpdateType, | ||||||
|     }, |     }, | ||||||
|     auth::{decode_delete, decode_invite, decode_verify_email, ClientHeaders, Headers}, |     auth::{decode_delete, decode_invite, decode_verify_email, ClientHeaders, Headers}, | ||||||
|     crypto, |     crypto, | ||||||
|  | @ -34,6 +34,7 @@ pub fn routes() -> Vec<rocket::Route> { | ||||||
|         get_public_keys, |         get_public_keys, | ||||||
|         post_keys, |         post_keys, | ||||||
|         post_password, |         post_password, | ||||||
|  |         post_set_password, | ||||||
|         post_kdf, |         post_kdf, | ||||||
|         post_rotatekey, |         post_rotatekey, | ||||||
|         post_sstamp, |         post_sstamp, | ||||||
|  | @ -104,6 +105,19 @@ pub struct RegisterData { | ||||||
|     org_invite_token: Option<String>, |     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)] | #[derive(Debug, Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| struct KeysData { | 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") |                     err!("Registration email does not match invite email") | ||||||
|                 } |                 } | ||||||
|             } else if Invitation::take(&email, &mut conn).await { |             } else if Invitation::take(&email, &mut conn).await { | ||||||
|                 for membership in Membership::find_invited_by_user(&user.uuid, &mut conn).await.iter_mut() { |                 Membership::accept_user_invitations(&user.uuid, &mut conn).await?; | ||||||
|                     membership.status = MembershipStatus::Accepted as i32; |  | ||||||
|                     membership.save(&mut conn).await?; |  | ||||||
|                 } |  | ||||||
|                 user |                 user | ||||||
|             } else if CONFIG.is_signup_allowed(&email) |             } else if CONFIG.is_signup_allowed(&email) | ||||||
|                 || (CONFIG.emergency_access_allowed() |                 || (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) |                 || CONFIG.is_signup_allowed(&email) | ||||||
|                 || pending_emergency_access.is_some() |                 || pending_emergency_access.is_some() | ||||||
|             { |             { | ||||||
|                 User::new(email.clone()) |                 User::new(email.clone(), None) | ||||||
|             } else { |             } else { | ||||||
|                 err!("Registration not allowed or user already exists") |                 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")] | #[get("/accounts/profile")] | ||||||
| async fn profile(headers: Headers, mut conn: DbConn) -> Json<Value> { | async fn profile(headers: Headers, mut conn: DbConn) -> Json<Value> { | ||||||
|     Json(headers.user.to_json(&mut conn).await) |     Json(headers.user.to_json(&mut conn).await) | ||||||
|  | @ -1129,15 +1202,30 @@ struct SecretVerificationRequest { | ||||||
|     master_password_hash: String, |     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>")] | #[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 data: SecretVerificationRequest = data.into_inner(); | ||||||
|     let user = headers.user; |     let mut user = headers.user; | ||||||
| 
 | 
 | ||||||
|     if !user.check_valid_password(&data.master_password_hash) { |     if !user.check_valid_password(&data.master_password_hash) { | ||||||
|         err!("Invalid password") |         err!("Invalid password") | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     kdf_upgrade(&mut user, &data.master_password_hash, &mut conn).await?; | ||||||
|  | 
 | ||||||
|     Ok(Json(master_password_policy(&user, &conn).await)) |     Ok(Json(master_password_policy(&user, &conn).await)) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -239,7 +239,7 @@ async fn send_invite(data: Json<EmergencyAccessInviteData>, headers: Headers, mu | ||||||
|                 invitation.save(&mut conn).await?; |                 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.save(&mut conn).await?; | ||||||
|             (user, true) |             (user, true) | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  | @ -50,11 +50,12 @@ pub fn events_routes() -> Vec<Route> { | ||||||
| use rocket::{serde::json::Json, serde::json::Value, Catcher, Route}; | use rocket::{serde::json::Json, serde::json::Value, Catcher, Route}; | ||||||
| 
 | 
 | ||||||
| use crate::{ | use crate::{ | ||||||
|     api::{JsonResult, Notify, UpdateType}, |     api::{EmptyResult, JsonResult, Notify, UpdateType}, | ||||||
|     auth::Headers, |     auth::Headers, | ||||||
|     db::DbConn, |     db::{models::*, DbConn}, | ||||||
|     error::Error, |     error::Error, | ||||||
|     http_client::make_http_request, |     http_client::make_http_request, | ||||||
|  |     mail, | ||||||
|     util::parse_experimental_client_feature_flags, |     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(()) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -7,13 +7,13 @@ use std::collections::{HashMap, HashSet}; | ||||||
| use crate::api::admin::FAKE_ADMIN_UUID; | use crate::api::admin::FAKE_ADMIN_UUID; | ||||||
| use crate::{ | use crate::{ | ||||||
|     api::{ |     api::{ | ||||||
|         core::{log_event, two_factor, CipherSyncData, CipherSyncType}, |         core::{accept_org_invite, log_event, two_factor, CipherSyncData, CipherSyncType}, | ||||||
|         EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, |         EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, | ||||||
|     }, |     }, | ||||||
|     auth::{decode_invite, AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders}, |     auth::{decode_invite, AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders}, | ||||||
|     db::{models::*, DbConn}, |     db::{models::*, DbConn}, | ||||||
|     mail, |     mail, | ||||||
|     util::{convert_json_key_lcase_first, NumberOrString}, |     util::{convert_json_key_lcase_first, get_uuid, NumberOrString}, | ||||||
|     CONFIG, |     CONFIG, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | @ -43,6 +43,7 @@ pub fn routes() -> Vec<Route> { | ||||||
|         bulk_delete_organization_collections, |         bulk_delete_organization_collections, | ||||||
|         post_bulk_collections, |         post_bulk_collections, | ||||||
|         get_org_details, |         get_org_details, | ||||||
|  |         get_org_domain_sso_verified, | ||||||
|         get_members, |         get_members, | ||||||
|         send_invite, |         send_invite, | ||||||
|         reinvite_member, |         reinvite_member, | ||||||
|  | @ -60,6 +61,7 @@ pub fn routes() -> Vec<Route> { | ||||||
|         post_org_import, |         post_org_import, | ||||||
|         list_policies, |         list_policies, | ||||||
|         list_policies_token, |         list_policies_token, | ||||||
|  |         get_master_password_policy, | ||||||
|         get_policy, |         get_policy, | ||||||
|         put_policy, |         put_policy, | ||||||
|         get_organization_tax, |         get_organization_tax, | ||||||
|  | @ -103,6 +105,7 @@ pub fn routes() -> Vec<Route> { | ||||||
|         api_key, |         api_key, | ||||||
|         rotate_api_key, |         rotate_api_key, | ||||||
|         get_billing_metadata, |         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 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); |     let collection = Collection::new(org.uuid.clone(), data.collection_name, None); | ||||||
| 
 | 
 | ||||||
|     member.akey = data.key; |     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")] | #[get("/organizations/<org_id>/collections")] | ||||||
| async fn get_org_collections(org_id: OrganizationId, headers: ManagerHeadersLoose, mut conn: DbConn) -> JsonResult { | async fn get_org_collections(org_id: OrganizationId, headers: ManagerHeadersLoose, mut conn: DbConn) -> JsonResult { | ||||||
|     if org_id != headers.membership.org_uuid { |     if org_id != headers.membership.org_uuid { | ||||||
|  | @ -930,6 +961,39 @@ async fn _get_org_details( | ||||||
|     Ok(json!(ciphers_json)) |     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)] | #[derive(FromForm)] | ||||||
| struct GetOrgUserData { | struct GetOrgUserData { | ||||||
|     #[field(name = "includeCollections")] |     #[field(name = "includeCollections")] | ||||||
|  | @ -1063,7 +1127,7 @@ async fn send_invite( | ||||||
|                     Invitation::new(email).save(&mut conn).await?; |                     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?; |                 new_user.save(&mut conn).await?; | ||||||
|                 user_created = true; |                 user_created = true; | ||||||
|                 new_user |                 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.access_all = access_all; | ||||||
|         new_member.atype = new_type; |         new_member.atype = new_type; | ||||||
|         new_member.status = member_status; |         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") |         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 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) { |     if !claims.member_id.eq(&member_id) { | ||||||
|         err!("Error accepting the invitation", "Claim does not match the member_id") |         err!("Error accepting the invitation", "Claim does not match the member_id") | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     let member = &claims.member_id; |     let member_id = &claims.member_id; | ||||||
|     let org = &claims.org_id; |  | ||||||
| 
 |  | ||||||
|     Invitation::take(&claims.email, &mut conn).await; |     Invitation::take(&claims.email, &mut conn).await; | ||||||
| 
 | 
 | ||||||
|     // skip invitation logic when we were invited via the /admin panel
 |     // skip invitation logic when we were invited via the /admin panel
 | ||||||
|     if **member != FAKE_ADMIN_UUID { |     if **member_id != FAKE_ADMIN_UUID { | ||||||
|         let Some(mut member) = Membership::find_by_uuid_and_org(member, org, &mut conn).await else { |         let Some(mut member) = Membership::find_by_uuid_and_org(member_id, &claims.org_id, &mut conn).await else { | ||||||
|             err!("Error accepting the invitation") |             err!("Error accepting the invitation") | ||||||
|         }; |         }; | ||||||
| 
 | 
 | ||||||
|         if member.status != MembershipStatus::Invited as i32 { |         let reset_password_key = match OrgPolicy::org_is_reset_password_auto_enroll(&member.org_uuid, &mut conn).await { | ||||||
|             err!("User already accepted the invitation") |             true if data.reset_password_key.is_none() => err!("Reset password key is required, but not provided."), | ||||||
|         } |             true => data.reset_password_key, | ||||||
|  |             false => None, | ||||||
|  |         }; | ||||||
| 
 | 
 | ||||||
|         let master_password_required = OrgPolicy::org_is_reset_password_auto_enroll(org, &mut conn).await; |         // In case the user was invited before the mail was saved in db.
 | ||||||
|         if data.reset_password_key.is_none() && master_password_required { |         member.invited_by_email = member.invited_by_email.or(claims.invited_by_email); | ||||||
|             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
 |         accept_org_invite(&headers.user, member, reset_password_key, &mut conn).await?; | ||||||
|         // It returns different error messages per function.
 |     } else if CONFIG.mail_enabled() { | ||||||
|         if member.atype < MembershipType::Admin { |         // User was invited from /admin, so they are automatically confirmed
 | ||||||
|             match OrgPolicy::is_user_allowed(&member.user_uuid, &org_id, false, &mut conn).await { |         let org_name = CONFIG.invitation_org_name(); | ||||||
|                 Ok(_) => {} |         mail::send_invite_confirmed(&claims.email, &org_name).await?; | ||||||
|                 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; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         member.save(&mut conn).await?; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     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?; |  | ||||||
|         } |  | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     Ok(()) |     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 { | async fn get_policy(org_id: OrganizationId, pol_type: i32, headers: AdminHeaders, mut conn: DbConn) -> JsonResult { | ||||||
|     if org_id != headers.org_id { |     if org_id != headers.org_id { | ||||||
|         err!("Organization not found", "Organization id's do not match"); |         err!("Organization not found", "Organization id's do not match"); | ||||||
|     } |     } | ||||||
|  | 
 | ||||||
|     let Some(pol_type_enum) = OrgPolicyType::from_i32(pol_type) else { |     let Some(pol_type_enum) = OrgPolicyType::from_i32(pol_type) else { | ||||||
|         err!("Invalid or unsupported policy type") |         err!("Invalid or unsupported policy type") | ||||||
|     }; |     }; | ||||||
| 
 | 
 | ||||||
|     let policy = match OrgPolicy::find_by_org_and_type(&org_id, pol_type_enum, &mut conn).await { |     let policy = match OrgPolicy::find_by_org_and_type(&org_id, pol_type_enum, &mut conn).await { | ||||||
|         Some(p) => p, |         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())) |     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 { |     let mut policy = match OrgPolicy::find_by_org_and_type(&org_id, pol_type_enum, &mut conn).await { | ||||||
|         Some(p) => p, |         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; |     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
 |                     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.access_all = false; | ||||||
|                 new_member.atype = MembershipType::User as i32; |                 new_member.atype = MembershipType::User as i32; | ||||||
|                 new_member.status = member_status; |                 new_member.status = member_status; | ||||||
|  |  | ||||||
|  | @ -89,7 +89,7 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, mut conn: Db | ||||||
|                 Some(user) => user, // exists in vaultwarden
 |                 Some(user) => user, // exists in vaultwarden
 | ||||||
|                 None => { |                 None => { | ||||||
|                     // User does not exist yet
 |                     // 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?; |                     new_user.save(&mut conn).await?; | ||||||
| 
 | 
 | ||||||
|                     if !CONFIG.mail_enabled() { |                     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
 |                 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.set_external_id(Some(user_data.external_id.clone())); | ||||||
|             new_member.access_all = false; |             new_member.access_all = false; | ||||||
|             new_member.atype = MembershipType::User as i32; |             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?; |             new_member.save(&mut conn).await?; | ||||||
| 
 | 
 | ||||||
|             if CONFIG.mail_enabled() { |             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) = |                 if let Err(e) = | ||||||
|                     mail::send_invite(&user, org_id.clone(), new_member.uuid.clone(), &org_name, Some(org_email)).await |                     mail::send_invite(&user, org_id.clone(), new_member.uuid.clone(), &org_name, Some(org_email)).await | ||||||
|                 { |                 { | ||||||
|  |  | ||||||
|  | @ -10,7 +10,7 @@ use crate::{ | ||||||
|     auth::Headers, |     auth::Headers, | ||||||
|     crypto, |     crypto, | ||||||
|     db::{ |     db::{ | ||||||
|         models::{EventType, TwoFactor, TwoFactorType, User, UserId}, |         models::{DeviceId, EventType, TwoFactor, TwoFactorType, User, UserId}, | ||||||
|         DbConn, |         DbConn, | ||||||
|     }, |     }, | ||||||
|     error::{Error, MapResult}, |     error::{Error, MapResult}, | ||||||
|  | @ -24,11 +24,15 @@ pub fn routes() -> Vec<Route> { | ||||||
| #[derive(Deserialize)] | #[derive(Deserialize)] | ||||||
| #[serde(rename_all = "camelCase")] | #[serde(rename_all = "camelCase")] | ||||||
| struct SendEmailLoginData { | struct SendEmailLoginData { | ||||||
|     // DeviceIdentifier: String, // Currently not used
 |     device_identifier: DeviceId, | ||||||
|  | 
 | ||||||
|  |     #[allow(unused)] | ||||||
|     #[serde(alias = "Email")] |     #[serde(alias = "Email")] | ||||||
|     email: String, |     email: Option<String>, | ||||||
|  | 
 | ||||||
|  |     #[allow(unused)] | ||||||
|     #[serde(alias = "MasterPasswordHash")] |     #[serde(alias = "MasterPasswordHash")] | ||||||
|     master_password_hash: String, |     master_password_hash: Option<String>, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| /// User is trying to login and wants to use email 2FA.
 | /// 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; |     use crate::db::models::User; | ||||||
| 
 | 
 | ||||||
|     // Get the user
 |     // Get the user
 | ||||||
|     let Some(user) = User::find_by_mail(&data.email, &mut conn).await else { |     let Some(user) = User::find_by_device_id(&data.device_identifier, &mut conn).await else { | ||||||
|         err!("Username or password is incorrect. Try again.") |         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() { |     if !CONFIG._enable_email_2fa() { | ||||||
|         err!("Email 2FA is disabled") |         err!("Email 2FA is disabled") | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  | @ -1,8 +1,10 @@ | ||||||
| use chrono::Utc; | use chrono::{NaiveDateTime, Utc}; | ||||||
| use num_traits::FromPrimitive; | use num_traits::FromPrimitive; | ||||||
| use rocket::serde::json::Json; |  | ||||||
| use rocket::{ | use rocket::{ | ||||||
|     form::{Form, FromForm}, |     form::{Form, FromForm}, | ||||||
|  |     http::Status, | ||||||
|  |     response::Redirect, | ||||||
|  |     serde::json::Json, | ||||||
|     Route, |     Route, | ||||||
| }; | }; | ||||||
| use serde_json::Value; | use serde_json::Value; | ||||||
|  | @ -10,7 +12,7 @@ use serde_json::Value; | ||||||
| use crate::{ | use crate::{ | ||||||
|     api::{ |     api::{ | ||||||
|         core::{ |         core::{ | ||||||
|             accounts::{PreloginData, RegisterData, _prelogin, _register}, |             accounts::{PreloginData, RegisterData, _prelogin, _register, kdf_upgrade}, | ||||||
|             log_user_event, |             log_user_event, | ||||||
|             two_factor::{authenticator, duo, duo_oidc, email, enforce_2fa_policy, webauthn, yubikey}, |             two_factor::{authenticator, duo, duo_oidc, email, enforce_2fa_policy, webauthn, yubikey}, | ||||||
|         }, |         }, | ||||||
|  | @ -18,14 +20,27 @@ use crate::{ | ||||||
|         push::register_push_device, |         push::register_push_device, | ||||||
|         ApiResult, EmptyResult, JsonResult, |         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}, |     db::{models::*, DbConn}, | ||||||
|     error::MapResult, |     error::MapResult, | ||||||
|     mail, util, CONFIG, |     mail, sso, | ||||||
|  |     sso::{OIDCCode, OIDCState}, | ||||||
|  |     util, CONFIG, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| pub fn routes() -> Vec<Route> { | 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>")] | #[post("/connect/token", data = "<data>")] | ||||||
|  | @ -42,8 +57,9 @@ async fn login( | ||||||
|     let login_result = match data.grant_type.as_ref() { |     let login_result = match data.grant_type.as_ref() { | ||||||
|         "refresh_token" => { |         "refresh_token" => { | ||||||
|             _check_is_some(&data.refresh_token, "refresh_token cannot be blank")?; |             _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" => { |         "password" => { | ||||||
|             _check_is_some(&data.client_id, "client_id cannot be blank")?; |             _check_is_some(&data.client_id, "client_id cannot be blank")?; | ||||||
|             _check_is_some(&data.password, "password 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 |             _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), |         t => err!("Invalid type", t), | ||||||
|     }; |     }; | ||||||
| 
 | 
 | ||||||
|  | @ -100,37 +127,193 @@ async fn login( | ||||||
|     login_result |     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
 |     // Extract token
 | ||||||
|     let token = data.refresh_token.unwrap(); |     let refresh_token = match data.refresh_token { | ||||||
|  |         Some(token) => token, | ||||||
|  |         None => err_code!("Missing refresh_token", Status::Unauthorized.code), | ||||||
|  |     }; | ||||||
| 
 | 
 | ||||||
|     // 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()]; |  | ||||||
| 
 |  | ||||||
|     // Common
 |  | ||||||
|     let user = User::find_by_uuid(&device.user_uuid, conn).await.unwrap(); |  | ||||||
|     // ---
 |     // ---
 | ||||||
|     // Disabled this variable, it was used to generate the JWT
 |     // 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
 |     // 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
 |     // See: https://github.com/dani-garcia/vaultwarden/issues/4156
 | ||||||
|     // ---
 |     // ---
 | ||||||
|     // let members = Membership::find_confirmed_by_user(&user.uuid, conn).await;
 |     // 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); |     match auth::refresh_tokens(ip, &refresh_token, data.client_id, conn).await { | ||||||
|     device.save(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!({ |             let result = json!({ | ||||||
|         "access_token": access_token, |                 "refresh_token": auth_tokens.refresh_token(), | ||||||
|         "expires_in": expires_in, |                 "access_token": auth_tokens.access_token(), | ||||||
|         "token_type": "Bearer", |                 "expires_in": auth_tokens.expires_in(), | ||||||
|         "refresh_token": device.refresh_token, |                 "token_type": "Bearer", | ||||||
|  |                 "scope": auth_tokens.scope(), | ||||||
|  |             }); | ||||||
| 
 | 
 | ||||||
|         "scope": scope, |             Ok(Json(result)) | ||||||
|     }); |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| 
 | 
 | ||||||
|     Ok(Json(result)) | // 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())?; | ||||||
|  | 
 | ||||||
|  |     // Ratelimit the login
 | ||||||
|  |     crate::ratelimit::check_limit_login(&ip.ip)?; | ||||||
|  | 
 | ||||||
|  |     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( | async fn _password_login( | ||||||
|  | @ -141,11 +324,7 @@ async fn _password_login( | ||||||
|     client_version: &Option<ClientVersion>, |     client_version: &Option<ClientVersion>, | ||||||
| ) -> JsonResult { | ) -> JsonResult { | ||||||
|     // Validate scope
 |     // Validate scope
 | ||||||
|     let scope = data.scope.as_ref().unwrap(); |     AuthMethod::Password.check_scope(data.scope.as_ref())?; | ||||||
|     if scope != "api offline_access" { |  | ||||||
|         err!("Scope not supported") |  | ||||||
|     } |  | ||||||
|     let scope_vec = vec!["api".into(), "offline_access".into()]; |  | ||||||
| 
 | 
 | ||||||
|     // Ratelimit the login
 |     // Ratelimit the login
 | ||||||
|     crate::ratelimit::check_limit_login(&ip.ip)?; |     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)
 |     // 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() { |     if data.auth_request.is_none() { | ||||||
|         user.password_iterations = CONFIG.password_iterations(); |         kdf_upgrade(&mut user, password, conn).await?; | ||||||
|         user.set_password(password, None, false, None); |  | ||||||
| 
 |  | ||||||
|         if let Err(e) = user.save(conn).await { |  | ||||||
|             error!("Error updating user: {e:#?}"); |  | ||||||
|         } |  | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     let now = Utc::now().naive_utc(); |     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?; |     let twofactor_token = twofactor_auth(&user, &data, &mut device, ip, client_version, conn).await?; | ||||||
| 
 | 
 | ||||||
|     if CONFIG.mail_enabled() && new_device { |     let auth_tokens = auth::AuthTokens::new(&device, &user, AuthMethod::Password, data.client_id); | ||||||
|         if let Err(e) = mail::send_new_device_logged_in(&user.email, &ip.ip.to_string(), &now, &device).await { | 
 | ||||||
|  |     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:#?}"); |             error!("Error sending new device email: {e:#?}"); | ||||||
| 
 | 
 | ||||||
|             if CONFIG.require_device_email() { |             if CONFIG.require_device_email() { | ||||||
|  | @ -275,31 +464,21 @@ async fn _password_login( | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     // register push device
 |     // register push device
 | ||||||
|     if !new_device { |     if !device.is_new() { | ||||||
|         register_push_device(&mut device, conn).await?; |         register_push_device(device, conn).await?; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     // Common
 |     // Save to update `device.updated_at` to track usage and toggle new status
 | ||||||
|     // ---
 |  | ||||||
|     // 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?; |     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!({ |     let mut result = json!({ | ||||||
|         "access_token": access_token, |         "access_token": auth_tokens.access_token(), | ||||||
|         "expires_in": expires_in, |         "expires_in": auth_tokens.expires_in(), | ||||||
|         "token_type": "Bearer", |         "token_type": "Bearer", | ||||||
|         "refresh_token": device.refresh_token, |         "refresh_token": auth_tokens.refresh_token(), | ||||||
|         "Key": user.akey, |  | ||||||
|         "PrivateKey": user.private_key, |         "PrivateKey": user.private_key, | ||||||
|         //"TwoFactorToken": "11122233333444555666777888999"
 |  | ||||||
| 
 |  | ||||||
|         "Kdf": user.client_kdf_type, |         "Kdf": user.client_kdf_type, | ||||||
|         "KdfIterations": user.client_kdf_iter, |         "KdfIterations": user.client_kdf_iter, | ||||||
|         "KdfMemory": user.client_kdf_memory, |         "KdfMemory": user.client_kdf_memory, | ||||||
|  | @ -307,19 +486,22 @@ async fn _password_login( | ||||||
|         "ResetMasterPassword": false, // TODO: Same as above
 |         "ResetMasterPassword": false, // TODO: Same as above
 | ||||||
|         "ForcePasswordReset": false, |         "ForcePasswordReset": false, | ||||||
|         "MasterPasswordPolicy": master_password_policy, |         "MasterPasswordPolicy": master_password_policy, | ||||||
| 
 |         "scope": auth_tokens.scope(), | ||||||
|         "scope": scope, |  | ||||||
|         "UserDecryptionOptions": { |         "UserDecryptionOptions": { | ||||||
|             "HasMasterPassword": !user.password_hash.is_empty(), |             "HasMasterPassword": !user.password_hash.is_empty(), | ||||||
|             "Object": "userDecryptionOptions" |             "Object": "userDecryptionOptions" | ||||||
|         }, |         }, | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|  |     if !user.akey.is_empty() { | ||||||
|  |         result["Key"] = Value::String(user.akey.clone()); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     if let Some(token) = twofactor_token { |     if let Some(token) = twofactor_token { | ||||||
|         result["TwoFactorToken"] = Value::String(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)) |     Ok(Json(result)) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -333,9 +515,9 @@ async fn _api_key_login( | ||||||
|     crate::ratelimit::check_limit_login(&ip.ip)?; |     crate::ratelimit::check_limit_login(&ip.ip)?; | ||||||
| 
 | 
 | ||||||
|     // Validate scope
 |     // Validate scope
 | ||||||
|     match data.scope.as_ref().unwrap().as_ref() { |     match data.scope.as_ref() { | ||||||
|         "api" => _user_api_key_login(data, user_id, conn, ip).await, |         Some(scope) if scope == &AuthMethod::UserApiKey.scope() => _user_api_key_login(data, user_id, conn, ip).await, | ||||||
|         "api.organization" => _organization_api_key_login(data, conn, ip).await, |         Some(scope) if scope == &AuthMethod::OrgApiKey.scope() => _organization_api_key_login(data, conn, ip).await, | ||||||
|         _ => err!("Scope not supported"), |         _ => 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(); |         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 { |         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:#?}"); |             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
 |     // 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
 |     // 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
 |     // See: https://github.com/dani-garcia/vaultwarden/issues/4156
 | ||||||
|     // ---
 |     // ---
 | ||||||
|     // let members = Membership::find_confirmed_by_user(&user.uuid, conn).await;
 |     // let orgs = Membership::find_confirmed_by_user(&user.uuid, conn).await;
 | ||||||
|     let (access_token, expires_in) = device.refresh_tokens(&user, scope_vec, data.client_id); |     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?; |     device.save(conn).await?; | ||||||
| 
 | 
 | ||||||
|     info!("User {} logged in successfully via API key. IP: {}", user.email, ip.ip); |     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
 |     // Note: No refresh_token is returned. The CLI just repeats the
 | ||||||
|     // client_credentials login flow when the existing token expires.
 |     // client_credentials login flow when the existing token expires.
 | ||||||
|     let result = json!({ |     let result = json!({ | ||||||
|         "access_token": access_token, |         "access_token": access_claims.token(), | ||||||
|         "expires_in": expires_in, |         "expires_in": access_claims.expires_in(), | ||||||
|         "token_type": "Bearer", |         "token_type": "Bearer", | ||||||
|         "Key": user.akey, |         "Key": user.akey, | ||||||
|         "PrivateKey": user.private_key, |         "PrivateKey": user.private_key, | ||||||
|  | @ -427,7 +609,7 @@ async fn _user_api_key_login( | ||||||
|         "KdfMemory": user.client_kdf_memory, |         "KdfMemory": user.client_kdf_memory, | ||||||
|         "KdfParallelism": user.client_kdf_parallelism, |         "KdfParallelism": user.client_kdf_parallelism, | ||||||
|         "ResetMasterPassword": false, // TODO: according to official server seems something like: user.password_hash.is_empty(), but would need testing
 |         "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)) |     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 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!({ |     Ok(Json(json!({ | ||||||
|         "access_token": access_token, |         "access_token": access_token, | ||||||
|         "expires_in": 3600, |         "expires_in": 3600, | ||||||
|         "token_type": "Bearer", |         "token_type": "Bearer", | ||||||
|         "scope": "api.organization", |         "scope": AuthMethod::OrgApiKey.scope(), | ||||||
|     }))) |     }))) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| /// Retrieves an existing device or creates a new device from ConnectData and the User
 | /// 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
 |     // On iOS, device_type sends "iOS", on others it sends a number
 | ||||||
|     // When unknown or unable to parse, return 14, which is 'Unknown Browser'
 |     // 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_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_id = data.device_identifier.clone().expect("No device id provided"); | ||||||
|     let device_name = data.device_name.clone().expect("No device name provided"); |     let device_name = data.device_name.clone().expect("No device name provided"); | ||||||
| 
 | 
 | ||||||
|     let mut new_device = false; |  | ||||||
|     // Find device or create new
 |     // Find device or create new
 | ||||||
|     let device = match Device::find_by_uuid_and_user(&device_id, &user.uuid, conn).await { |     match Device::find_by_uuid_and_user(&device_id, &user.uuid, conn).await { | ||||||
|         Some(device) => device, |         Some(device) => Ok(device), | ||||||
|         None => { |         None => Device::new(device_id, user.uuid.clone(), device_name, device_type, conn).await, | ||||||
|             new_device = true; |     } | ||||||
|             Device::new(device_id, user.uuid.clone(), device_name, device_type) |  | ||||||
|         } |  | ||||||
|     }; |  | ||||||
| 
 |  | ||||||
|     (device, new_device) |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| async fn twofactor_auth( | async fn twofactor_auth( | ||||||
|  | @ -572,12 +748,13 @@ async fn twofactor_auth( | ||||||
| 
 | 
 | ||||||
|     TwoFactorIncomplete::mark_complete(&user.uuid, &device.uuid, conn).await?; |     TwoFactorIncomplete::mark_complete(&user.uuid, &device.uuid, conn).await?; | ||||||
| 
 | 
 | ||||||
|     if !CONFIG.disable_2fa_remember() && remember == 1 { |     let two_factor = if !CONFIG.disable_2fa_remember() && remember == 1 { | ||||||
|         Ok(Some(device.refresh_twofactor_remember())) |         Some(device.refresh_twofactor_remember()) | ||||||
|     } else { |     } else { | ||||||
|         device.delete_twofactor_remember(); |         device.delete_twofactor_remember(); | ||||||
|         Ok(None) |         None | ||||||
|     } |     }; | ||||||
|  |     Ok(two_factor) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| fn _selected_data(tf: Option<TwoFactor>) -> ApiResult<String> { | 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 should_send_mail = CONFIG.mail_enabled() && CONFIG.signups_verify(); | ||||||
| 
 | 
 | ||||||
|     let token_claims = |     let token_claims = auth::generate_register_verify_claims(data.email.clone(), data.name.clone(), should_send_mail); | ||||||
|         crate::auth::generate_register_verify_claims(data.email.clone(), data.name.clone(), should_send_mail); |     let token = auth::encode_jwt(&token_claims); | ||||||
|     let token = crate::auth::encode_jwt(&token_claims); |  | ||||||
| 
 | 
 | ||||||
|     if should_send_mail { |     if should_send_mail { | ||||||
|         let user = User::find_by_mail(&data.email, &mut conn).await; |         let user = User::find_by_mail(&data.email, &mut conn).await; | ||||||
|  | @ -812,11 +988,131 @@ struct ConnectData { | ||||||
|     two_factor_remember: Option<i32>, |     two_factor_remember: Option<i32>, | ||||||
|     #[field(name = uncased("authrequest"))] |     #[field(name = uncased("authrequest"))] | ||||||
|     auth_request: Option<AuthRequestId>, |     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 { | fn _check_is_some<T>(value: &Option<T>, msg: &str) -> EmptyResult { | ||||||
|     if value.is_none() { |     if value.is_none() { | ||||||
|         err!(msg) |         err!(msg) | ||||||
|     } |     } | ||||||
|     Ok(()) |     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))) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -36,9 +36,10 @@ use crate::db::{ | ||||||
|     models::{OrgPolicy, OrgPolicyType, User}, |     models::{OrgPolicy, OrgPolicyType, User}, | ||||||
|     DbConn, |     DbConn, | ||||||
| }; | }; | ||||||
|  | use crate::CONFIG; | ||||||
| 
 | 
 | ||||||
| // Type aliases for API methods results
 | // 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 JsonResult = ApiResult<Json<Value>>; | ||||||
| pub type EmptyResult = ApiResult<()>; | pub type EmptyResult = ApiResult<()>; | ||||||
| 
 | 
 | ||||||
|  | @ -109,6 +110,8 @@ async fn master_password_policy(user: &User, conn: &DbConn) -> Value { | ||||||
|                 enforce_on_login: acc.enforce_on_login || policy.enforce_on_login, |                 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 { |     } else { | ||||||
|         json!({}) |         json!({}) | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|  | @ -55,13 +55,15 @@ fn not_found() -> ApiResult<Html<String>> { | ||||||
| #[get("/css/vaultwarden.css")] | #[get("/css/vaultwarden.css")] | ||||||
| fn vaultwarden_css() -> Cached<Css<String>> { | fn vaultwarden_css() -> Cached<Css<String>> { | ||||||
|     let css_options = json!({ |     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(), |         "emergency_access_allowed": CONFIG.emergency_access_allowed(), | ||||||
|         "sends_allowed": CONFIG.sends_allowed(), |  | ||||||
|         "load_user_scss": true, |         "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) { |     let scss = match CONFIG.render_template("scss/vaultwarden.scss", &css_options) { | ||||||
|  |  | ||||||
							
								
								
									
										254
									
								
								src/auth.rs
									
										
									
									
									
								
							
							
						
						
									
										254
									
								
								src/auth.rs
									
										
									
									
									
								
							|  | @ -1,6 +1,5 @@ | ||||||
| // JWT Handling
 | // JWT Handling
 | ||||||
| //
 | use chrono::{DateTime, TimeDelta, Utc}; | ||||||
| use chrono::{TimeDelta, Utc}; |  | ||||||
| use jsonwebtoken::{errors::ErrorKind, Algorithm, DecodingKey, EncodingKey, Header}; | use jsonwebtoken::{errors::ErrorKind, Algorithm, DecodingKey, EncodingKey, Header}; | ||||||
| use num_traits::FromPrimitive; | use num_traits::FromPrimitive; | ||||||
| use once_cell::sync::{Lazy, OnceCell}; | use once_cell::sync::{Lazy, OnceCell}; | ||||||
|  | @ -10,17 +9,24 @@ use serde::ser::Serialize; | ||||||
| use std::{env, net::IpAddr}; | use std::{env, net::IpAddr}; | ||||||
| 
 | 
 | ||||||
| use crate::{ | use crate::{ | ||||||
|  |     api::ApiResult, | ||||||
|     config::PathType, |     config::PathType, | ||||||
|     db::models::{ |     db::models::{ | ||||||
|         AttachmentId, CipherId, CollectionId, DeviceId, EmergencyAccessId, MembershipId, OrgApiKeyId, OrganizationId, |         AttachmentId, CipherId, CollectionId, DeviceId, DeviceType, EmergencyAccessId, MembershipId, OrgApiKeyId, | ||||||
|         SendFileId, SendId, UserId, |         OrganizationId, SendFileId, SendId, UserId, | ||||||
|     }, |     }, | ||||||
|  |     error::Error, | ||||||
|  |     sso, CONFIG, | ||||||
| }; | }; | ||||||
| use crate::{error::Error, CONFIG}; |  | ||||||
| 
 | 
 | ||||||
| const JWT_ALGORITHM: Algorithm = Algorithm::RS256; | 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)); | 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())); | 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); |     let mut validation = jsonwebtoken::Validation::new(JWT_ALGORITHM); | ||||||
|     validation.leeway = 30; // 30 seconds
 |     validation.leeway = 30; // 30 seconds
 | ||||||
|     validation.validate_exp = true; |     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::InvalidToken => err!("Token is invalid"), | ||||||
|             ErrorKind::InvalidIssuer => err!("Issuer is invalid"), |             ErrorKind::InvalidIssuer => err!("Issuer is invalid"), | ||||||
|             ErrorKind::ExpiredSignature => err!("Token has expired"), |             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> { | pub fn decode_login(token: &str) -> Result<LoginJwtClaims, Error> { | ||||||
|     decode_jwt(token, JWT_LOGIN_ISSUER.to_string()) |     decode_jwt(token, JWT_LOGIN_ISSUER.to_string()) | ||||||
| } | } | ||||||
|  | @ -186,6 +196,84 @@ pub struct LoginJwtClaims { | ||||||
|     pub amr: Vec<String>, |     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)] | #[derive(Debug, Serialize, Deserialize)] | ||||||
| pub struct InviteJwtClaims { | pub struct InviteJwtClaims { | ||||||
|     // Not before
 |     // Not before
 | ||||||
|  | @ -1001,3 +1089,153 @@ impl<'r> FromRequest<'r> for ClientVersion { | ||||||
|         Outcome::Success(ClientVersion(version)) |         Outcome::Success(ClientVersion(version)) | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | #[derive(Clone, Debug, Ord, PartialOrd, Eq, PartialEq, Serialize, Deserialize)] | ||||||
|  | #[serde(rename_all = "lowercase")] | ||||||
|  | pub enum AuthMethod { | ||||||
|  |     OrgApiKey, | ||||||
|  |     Password, | ||||||
|  |     Sso, | ||||||
|  |     UserApiKey, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl AuthMethod { | ||||||
|  |     pub fn scope(&self) -> String { | ||||||
|  |         match self { | ||||||
|  |             AuthMethod::OrgApiKey => "api.organization".to_string(), | ||||||
|  |             AuthMethod::Password => "api offline_access".to_string(), | ||||||
|  |             AuthMethod::Sso => "api offline_access".to_string(), | ||||||
|  |             AuthMethod::UserApiKey => "api".to_string(), | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     pub fn scope_vec(&self) -> Vec<String> { | ||||||
|  |         self.scope().split_whitespace().map(str::to_string).collect() | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     pub fn check_scope(&self, scope: Option<&String>) -> ApiResult<String> { | ||||||
|  |         let method_scope = self.scope(); | ||||||
|  |         match scope { | ||||||
|  |             None => err!("Missing scope"), | ||||||
|  |             Some(scope) if scope == &method_scope => Ok(method_scope), | ||||||
|  |             Some(scope) => err!(format!("Scope ({scope}) not supported")), | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[derive(Debug, Serialize, Deserialize)] | ||||||
|  | pub enum TokenWrapper { | ||||||
|  |     Access(String), | ||||||
|  |     Refresh(String), | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[derive(Debug, Serialize, Deserialize)] | ||||||
|  | pub struct RefreshJwtClaims { | ||||||
|  |     // Not before
 | ||||||
|  |     pub nbf: i64, | ||||||
|  |     // Expiration time
 | ||||||
|  |     pub exp: i64, | ||||||
|  |     // Issuer
 | ||||||
|  |     pub iss: String, | ||||||
|  |     // Subject
 | ||||||
|  |     pub sub: AuthMethod, | ||||||
|  | 
 | ||||||
|  |     pub device_token: String, | ||||||
|  | 
 | ||||||
|  |     pub token: Option<TokenWrapper>, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[derive(Debug, Serialize, Deserialize)] | ||||||
|  | pub struct AuthTokens { | ||||||
|  |     pub refresh_claims: RefreshJwtClaims, | ||||||
|  |     pub access_claims: LoginJwtClaims, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl AuthTokens { | ||||||
|  |     pub fn refresh_token(&self) -> String { | ||||||
|  |         encode_jwt(&self.refresh_claims) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     pub fn access_token(&self) -> String { | ||||||
|  |         self.access_claims.token() | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     pub fn expires_in(&self) -> i64 { | ||||||
|  |         self.access_claims.expires_in() | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     pub fn scope(&self) -> String { | ||||||
|  |         self.refresh_claims.sub.scope() | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     // Create refresh_token and access_token with default validity
 | ||||||
|  |     pub fn new(device: &Device, user: &User, sub: AuthMethod, client_id: Option<String>) -> Self { | ||||||
|  |         let time_now = Utc::now(); | ||||||
|  | 
 | ||||||
|  |         let access_claims = LoginJwtClaims::default(device, user, &sub, client_id); | ||||||
|  | 
 | ||||||
|  |         let validity = if DeviceType::is_mobile(&device.atype) { | ||||||
|  |             *MOBILE_REFRESH_VALIDITY | ||||||
|  |         } else { | ||||||
|  |             *DEFAULT_REFRESH_VALIDITY | ||||||
|  |         }; | ||||||
|  | 
 | ||||||
|  |         let refresh_claims = RefreshJwtClaims { | ||||||
|  |             nbf: time_now.timestamp(), | ||||||
|  |             exp: (time_now + validity).timestamp(), | ||||||
|  |             iss: JWT_LOGIN_ISSUER.to_string(), | ||||||
|  |             sub, | ||||||
|  |             device_token: device.refresh_token.clone(), | ||||||
|  |             token: None, | ||||||
|  |         }; | ||||||
|  | 
 | ||||||
|  |         Self { | ||||||
|  |             refresh_claims, | ||||||
|  |             access_claims, | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | pub async fn refresh_tokens( | ||||||
|  |     ip: &ClientIp, | ||||||
|  |     refresh_token: &str, | ||||||
|  |     client_id: Option<String>, | ||||||
|  |     conn: &mut DbConn, | ||||||
|  | ) -> ApiResult<(Device, AuthTokens)> { | ||||||
|  |     let refresh_claims = match decode_refresh(refresh_token) { | ||||||
|  |         Err(err) => { | ||||||
|  |             debug!("Failed to decode {} refresh_token: {refresh_token}", ip.ip); | ||||||
|  |             err_silent!(format!("Impossible to read refresh_token: {}", err.message())) | ||||||
|  |         } | ||||||
|  |         Ok(claims) => claims, | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     // Get device by refresh token
 | ||||||
|  |     let mut device = match Device::find_by_refresh_token(&refresh_claims.device_token, conn).await { | ||||||
|  |         None => err!("Invalid refresh token"), | ||||||
|  |         Some(device) => device, | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     // Save to update `updated_at`.
 | ||||||
|  |     device.save(conn).await?; | ||||||
|  | 
 | ||||||
|  |     let user = match User::find_by_uuid(&device.user_uuid, conn).await { | ||||||
|  |         None => err!("Impossible to find user"), | ||||||
|  |         Some(user) => user, | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     let auth_tokens = match refresh_claims.sub { | ||||||
|  |         AuthMethod::Sso if CONFIG.sso_enabled() && CONFIG.sso_auth_only_not_session() => { | ||||||
|  |             AuthTokens::new(&device, &user, refresh_claims.sub, client_id) | ||||||
|  |         } | ||||||
|  |         AuthMethod::Sso if CONFIG.sso_enabled() => { | ||||||
|  |             sso::exchange_refresh_token(&device, &user, client_id, refresh_claims).await? | ||||||
|  |         } | ||||||
|  |         AuthMethod::Sso => err!("SSO is now disabled, Login again using email and master password"), | ||||||
|  |         AuthMethod::Password if CONFIG.sso_enabled() && CONFIG.sso_only() => err!("SSO is now required, Login again"), | ||||||
|  |         AuthMethod::Password => AuthTokens::new(&device, &user, refresh_claims.sub, client_id), | ||||||
|  |         _ => err!("Invalid auth method, cannot refresh token"), | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     Ok((device, auth_tokens)) | ||||||
|  | } | ||||||
|  |  | ||||||
							
								
								
									
										102
									
								
								src/config.rs
									
										
									
									
									
								
							
							
						
						
									
										102
									
								
								src/config.rs
									
										
									
									
									
								
							|  | @ -458,6 +458,9 @@ make_config! { | ||||||
|         /// Duo Auth context cleanup schedule |> Cron schedule of the job that cleans expired Duo contexts from the database. Does nothing if Duo MFA is disabled or set to use the legacy iframe prompt.
 |         /// 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.
 |         /// Defaults to once every minute. Set blank to disable this job.
 | ||||||
|         duo_context_purge_schedule:   String, false,  def,    "30 * * * * *".to_string(); |         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
 |     /// General settings
 | ||||||
|  | @ -676,6 +679,42 @@ make_config! { | ||||||
|         enforce_single_org_with_reset_pw_policy: bool, false, def, false; |         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
 |     /// Yubikey settings
 | ||||||
|     yubico: _enable_yubico { |     yubico: _enable_yubico { | ||||||
|         /// Enabled
 |         /// Enabled
 | ||||||
|  | @ -911,6 +950,16 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> { | ||||||
|         err!("All Duo options need to be set for global Duo support") |         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._enable_yubico { | ||||||
|         if cfg.yubico_client_id.is_some() != cfg.yubico_secret_key.is_some() { |         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") |             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(()) |     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.
 | /// Extracts an RFC 6454 web origin from a URL.
 | ||||||
| fn extract_url_origin(url: &str) -> String { | fn extract_url_origin(url: &str) -> String { | ||||||
|     match Url::parse(url) { |     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.
 | /// Generate the correct URL for the icon service.
 | ||||||
| /// This will be used within icons.rs to call the external icon service.
 | /// This will be used within icons.rs to call the external icon service.
 | ||||||
| fn generate_icon_service_url(icon_service: &str) -> String { | 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
 |     // The registration link should be hidden if
 | ||||||
|     // unless mail is disabled and invitations are allowed
 |     //  - 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 { |     pub fn is_signup_disabled(&self) -> bool { | ||||||
|         !self.signups_allowed() |         (!self.signups_allowed() | ||||||
|             && self.signups_domains_whitelist().is_empty() |             && 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.
 |     /// 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::{ | use handlebars::{ | ||||||
|  | @ -1540,6 +1633,7 @@ where | ||||||
|     reg!("email/send_org_invite", ".html"); |     reg!("email/send_org_invite", ".html"); | ||||||
|     reg!("email/send_single_org_removed_from_org", ".html"); |     reg!("email/send_single_org_removed_from_org", ".html"); | ||||||
|     reg!("email/smtp_test", ".html"); |     reg!("email/smtp_test", ".html"); | ||||||
|  |     reg!("email/sso_change_email", ".html"); | ||||||
|     reg!("email/twofactor_email", ".html"); |     reg!("email/twofactor_email", ".html"); | ||||||
|     reg!("email/verify_email", ".html"); |     reg!("email/verify_email", ".html"); | ||||||
|     reg!("email/welcome_must_verify", ".html"); |     reg!("email/welcome_must_verify", ".html"); | ||||||
|  |  | ||||||
|  | @ -1,4 +1,6 @@ | ||||||
| use chrono::{NaiveDateTime, Utc}; | use chrono::{NaiveDateTime, Utc}; | ||||||
|  | 
 | ||||||
|  | use data_encoding::{BASE64, BASE64URL}; | ||||||
| use derive_more::{Display, From}; | use derive_more::{Display, From}; | ||||||
| use serde_json::Value; | use serde_json::Value; | ||||||
| 
 | 
 | ||||||
|  | @ -6,7 +8,6 @@ use super::{AuthRequest, UserId}; | ||||||
| use crate::{ | use crate::{ | ||||||
|     crypto, |     crypto, | ||||||
|     util::{format_date, get_uuid}, |     util::{format_date, get_uuid}, | ||||||
|     CONFIG, |  | ||||||
| }; | }; | ||||||
| use macros::{IdFromParam, UuidFromParam}; | use macros::{IdFromParam, UuidFromParam}; | ||||||
| 
 | 
 | ||||||
|  | @ -34,25 +35,6 @@ db_object! { | ||||||
| 
 | 
 | ||||||
| /// Local methods
 | /// Local methods
 | ||||||
| impl Device { | 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 { |     pub fn to_json(&self) -> Value { | ||||||
|         json!({ |         json!({ | ||||||
|             "id": self.uuid, |             "id": self.uuid, | ||||||
|  | @ -66,7 +48,6 @@ impl Device { | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     pub fn refresh_twofactor_remember(&mut self) -> String { |     pub fn refresh_twofactor_remember(&mut self) -> String { | ||||||
|         use data_encoding::BASE64; |  | ||||||
|         let twofactor_remember = crypto::encode_random_bytes::<180>(BASE64); |         let twofactor_remember = crypto::encode_random_bytes::<180>(BASE64); | ||||||
|         self.twofactor_remember = Some(twofactor_remember.clone()); |         self.twofactor_remember = Some(twofactor_remember.clone()); | ||||||
| 
 | 
 | ||||||
|  | @ -77,71 +58,9 @@ impl Device { | ||||||
|         self.twofactor_remember = None; |         self.twofactor_remember = None; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     pub fn refresh_tokens( |     // This rely on the fact we only update the device after a successful login
 | ||||||
|         &mut self, |     pub fn is_new(&self) -> bool { | ||||||
|         user: &super::User, |         self.created_at == self.updated_at | ||||||
|         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()) |  | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     pub fn is_push_device(&self) -> bool { |     pub fn is_push_device(&self) -> bool { | ||||||
|  | @ -187,14 +106,39 @@ impl DeviceWithAuthRequest { | ||||||
| } | } | ||||||
| use crate::db::DbConn; | use crate::db::DbConn; | ||||||
| 
 | 
 | ||||||
| use crate::api::EmptyResult; | use crate::api::{ApiResult, EmptyResult}; | ||||||
| use crate::error::MapResult; | use crate::error::MapResult; | ||||||
| 
 | 
 | ||||||
| /// Database methods
 | /// Database methods
 | ||||||
| impl Device { | impl Device { | ||||||
|     pub async fn save(&mut self, conn: &mut DbConn) -> EmptyResult { |     pub async fn new( | ||||||
|         self.updated_at = Utc::now().naive_utc(); |         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: |         db_run! { conn: | ||||||
|             sqlite, mysql { |             sqlite, mysql { | ||||||
|                 crate::util::retry( |                 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 { |     pub async fn delete_all_by_user(user_uuid: &UserId, conn: &mut DbConn) -> EmptyResult { | ||||||
|         db_run! { conn: { |         db_run! { conn: { | ||||||
|             diesel::delete(devices::table.filter(devices::user_uuid.eq(user_uuid))) |             diesel::delete(devices::table.filter(devices::user_uuid.eq(user_uuid))) | ||||||
|  | @ -403,6 +353,10 @@ impl DeviceType { | ||||||
|             _ => DeviceType::UnknownBrowser, |             _ => DeviceType::UnknownBrowser, | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  | 
 | ||||||
|  |     pub fn is_mobile(value: &i32) -> bool { | ||||||
|  |         *value == DeviceType::Android as i32 || *value == DeviceType::Ios as i32 | ||||||
|  |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(
 | #[derive(
 | ||||||
|  |  | ||||||
|  | @ -89,7 +89,7 @@ pub enum EventType { | ||||||
|     OrganizationUserUpdated = 1502, |     OrganizationUserUpdated = 1502, | ||||||
|     OrganizationUserRemoved = 1503, // Organization user data was deleted
 |     OrganizationUserRemoved = 1503, // Organization user data was deleted
 | ||||||
|     OrganizationUserUpdatedGroups = 1504, |     OrganizationUserUpdatedGroups = 1504, | ||||||
|     // OrganizationUserUnlinkedSso = 1505, // Not supported
 |     OrganizationUserUnlinkedSso = 1505, | ||||||
|     OrganizationUserResetPasswordEnroll = 1506, |     OrganizationUserResetPasswordEnroll = 1506, | ||||||
|     OrganizationUserResetPasswordWithdraw = 1507, |     OrganizationUserResetPasswordWithdraw = 1507, | ||||||
|     OrganizationUserAdminResetPassword = 1508, |     OrganizationUserAdminResetPassword = 1508, | ||||||
|  |  | ||||||
|  | @ -11,6 +11,7 @@ mod group; | ||||||
| mod org_policy; | mod org_policy; | ||||||
| mod organization; | mod organization; | ||||||
| mod send; | mod send; | ||||||
|  | mod sso_nonce; | ||||||
| mod two_factor; | mod two_factor; | ||||||
| mod two_factor_duo_context; | mod two_factor_duo_context; | ||||||
| mod two_factor_incomplete; | mod two_factor_incomplete; | ||||||
|  | @ -35,7 +36,8 @@ pub use self::send::{ | ||||||
|     id::{SendFileId, SendId}, |     id::{SendFileId, SendId}, | ||||||
|     Send, SendType, |     Send, SendType, | ||||||
| }; | }; | ||||||
|  | pub use self::sso_nonce::SsoNonce; | ||||||
| pub use self::two_factor::{TwoFactor, TwoFactorType}; | pub use self::two_factor::{TwoFactor, TwoFactorType}; | ||||||
| pub use self::two_factor_duo_context::TwoFactorDuoContext; | pub use self::two_factor_duo_context::TwoFactorDuoContext; | ||||||
| pub use self::two_factor_incomplete::TwoFactorIncomplete; | 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}; | ||||||
|  |  | ||||||
|  | @ -67,12 +67,12 @@ pub enum OrgPolicyErr { | ||||||
| 
 | 
 | ||||||
| /// Local methods
 | /// Local methods
 | ||||||
| impl OrgPolicy { | 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 { |         Self { | ||||||
|             uuid: OrgPolicyId(crate::util::get_uuid()), |             uuid: OrgPolicyId(crate::util::get_uuid()), | ||||||
|             org_uuid, |             org_uuid, | ||||||
|             atype: atype as i32, |             atype: atype as i32, | ||||||
|             enabled: false, |             enabled, | ||||||
|             data, |             data, | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  | @ -36,6 +36,8 @@ db_object! { | ||||||
|         pub user_uuid: UserId, |         pub user_uuid: UserId, | ||||||
|         pub org_uuid: OrganizationId, |         pub org_uuid: OrganizationId, | ||||||
| 
 | 
 | ||||||
|  |         pub invited_by_email: Option<String>, | ||||||
|  | 
 | ||||||
|         pub access_all: bool, |         pub access_all: bool, | ||||||
|         pub akey: String, |         pub akey: String, | ||||||
|         pub status: i32, |         pub status: i32, | ||||||
|  | @ -235,12 +237,13 @@ impl Organization { | ||||||
| const ACTIVATE_REVOKE_DIFF: i32 = 128; | const ACTIVATE_REVOKE_DIFF: i32 = 128; | ||||||
| 
 | 
 | ||||||
| impl Membership { | 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 { |         Self { | ||||||
|             uuid: MembershipId(crate::util::get_uuid()), |             uuid: MembershipId(crate::util::get_uuid()), | ||||||
| 
 | 
 | ||||||
|             user_uuid, |             user_uuid, | ||||||
|             org_uuid, |             org_uuid, | ||||||
|  |             invited_by_email, | ||||||
| 
 | 
 | ||||||
|             access_all: false, |             access_all: false, | ||||||
|             akey: String::new(), |             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> { |     pub async fn get_all(conn: &mut DbConn) -> Vec<Self> { | ||||||
|         db_run! { conn: { |         db_run! { conn: { | ||||||
|             organizations::table.load::<OrganizationDb>(conn).expect("Error loading organizations").from_db() |             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 { | 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> { |     pub async fn find_any_state_by_user(user_uuid: &UserId, conn: &mut DbConn) -> Vec<Self> { | ||||||
|         db_run! { conn: { |         db_run! { conn: { | ||||||
|             users_organizations::table |             users_organizations::table | ||||||
|  | @ -1103,6 +1161,17 @@ impl Membership { | ||||||
|             .first::<MembershipDb>(conn).ok().from_db() |             .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 { | impl OrganizationApiKey { | ||||||
|  |  | ||||||
							
								
								
									
										89
									
								
								src/db/models/sso_nonce.rs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								src/db/models/sso_nonce.rs
									
										
									
									
									
										Normal file
									
								
							|  | @ -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") | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | @ -8,15 +8,17 @@ use super::{ | ||||||
| use crate::{ | use crate::{ | ||||||
|     api::EmptyResult, |     api::EmptyResult, | ||||||
|     crypto, |     crypto, | ||||||
|  |     db::models::DeviceId, | ||||||
|     db::DbConn, |     db::DbConn, | ||||||
|     error::MapResult, |     error::MapResult, | ||||||
|  |     sso::OIDCIdentifier, | ||||||
|     util::{format_date, get_uuid, retry}, |     util::{format_date, get_uuid, retry}, | ||||||
|     CONFIG, |     CONFIG, | ||||||
| }; | }; | ||||||
| use macros::UuidFromParam; | use macros::UuidFromParam; | ||||||
| 
 | 
 | ||||||
| db_object! { | db_object! { | ||||||
|     #[derive(Identifiable, Queryable, Insertable, AsChangeset)] |     #[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)] | ||||||
|     #[diesel(table_name = users)] |     #[diesel(table_name = users)] | ||||||
|     #[diesel(treat_none_as_null = true)] |     #[diesel(treat_none_as_null = true)] | ||||||
|     #[diesel(primary_key(uuid))] |     #[diesel(primary_key(uuid))] | ||||||
|  | @ -71,6 +73,14 @@ db_object! { | ||||||
|     pub struct Invitation { |     pub struct Invitation { | ||||||
|         pub email: String, |         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 { | pub enum UserKdfType { | ||||||
|  | @ -96,7 +106,7 @@ impl User { | ||||||
|     pub const CLIENT_KDF_TYPE_DEFAULT: i32 = UserKdfType::Pbkdf2 as i32; |     pub const CLIENT_KDF_TYPE_DEFAULT: i32 = UserKdfType::Pbkdf2 as i32; | ||||||
|     pub const CLIENT_KDF_ITER_DEFAULT: i32 = 600_000; |     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 now = Utc::now().naive_utc(); | ||||||
|         let email = email.to_lowercase(); |         let email = email.to_lowercase(); | ||||||
| 
 | 
 | ||||||
|  | @ -108,7 +118,7 @@ impl User { | ||||||
|             verified_at: None, |             verified_at: None, | ||||||
|             last_verifying_at: None, |             last_verifying_at: None, | ||||||
|             login_verify_count: 0, |             login_verify_count: 0, | ||||||
|             name: email.clone(), |             name: name.unwrap_or(email.clone()), | ||||||
|             email, |             email, | ||||||
|             akey: String::new(), |             akey: String::new(), | ||||||
|             email_new: None, |             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: { |         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)] | #[deref(forward)] | ||||||
| #[from(forward)] | #[from(forward)] | ||||||
| pub struct UserId(String); | 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") | ||||||
|  |         }} | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -235,6 +235,7 @@ table! { | ||||||
|         uuid -> Text, |         uuid -> Text, | ||||||
|         user_uuid -> Text, |         user_uuid -> Text, | ||||||
|         org_uuid -> Text, |         org_uuid -> Text, | ||||||
|  |         invited_by_email -> Nullable<Text>, | ||||||
|         access_all -> Bool, |         access_all -> Bool, | ||||||
|         akey -> Text, |         akey -> Text, | ||||||
|         status -> Integer, |         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! { | table! { | ||||||
|     emergency_access (uuid) { |     emergency_access (uuid) { | ||||||
|         uuid -> Text, |         uuid -> Text, | ||||||
|  | @ -348,6 +366,7 @@ joinable!(collections_groups -> collections (collections_uuid)); | ||||||
| joinable!(collections_groups -> groups (groups_uuid)); | joinable!(collections_groups -> groups (groups_uuid)); | ||||||
| joinable!(event -> users_organizations (uuid)); | joinable!(event -> users_organizations (uuid)); | ||||||
| joinable!(auth_requests -> users (user_uuid)); | joinable!(auth_requests -> users (user_uuid)); | ||||||
|  | joinable!(sso_users -> users (user_uuid)); | ||||||
| 
 | 
 | ||||||
| allow_tables_to_appear_in_same_query!( | allow_tables_to_appear_in_same_query!( | ||||||
|     attachments, |     attachments, | ||||||
|  | @ -361,6 +380,7 @@ allow_tables_to_appear_in_same_query!( | ||||||
|     org_policies, |     org_policies, | ||||||
|     organizations, |     organizations, | ||||||
|     sends, |     sends, | ||||||
|  |     sso_users, | ||||||
|     twofactor, |     twofactor, | ||||||
|     users, |     users, | ||||||
|     users_collections, |     users_collections, | ||||||
|  |  | ||||||
|  | @ -235,6 +235,7 @@ table! { | ||||||
|         uuid -> Text, |         uuid -> Text, | ||||||
|         user_uuid -> Text, |         user_uuid -> Text, | ||||||
|         org_uuid -> Text, |         org_uuid -> Text, | ||||||
|  |         invited_by_email -> Nullable<Text>, | ||||||
|         access_all -> Bool, |         access_all -> Bool, | ||||||
|         akey -> Text, |         akey -> Text, | ||||||
|         status -> Integer, |         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! { | table! { | ||||||
|     emergency_access (uuid) { |     emergency_access (uuid) { | ||||||
|         uuid -> Text, |         uuid -> Text, | ||||||
|  | @ -348,6 +366,7 @@ joinable!(collections_groups -> collections (collections_uuid)); | ||||||
| joinable!(collections_groups -> groups (groups_uuid)); | joinable!(collections_groups -> groups (groups_uuid)); | ||||||
| joinable!(event -> users_organizations (uuid)); | joinable!(event -> users_organizations (uuid)); | ||||||
| joinable!(auth_requests -> users (user_uuid)); | joinable!(auth_requests -> users (user_uuid)); | ||||||
|  | joinable!(sso_users -> users (user_uuid)); | ||||||
| 
 | 
 | ||||||
| allow_tables_to_appear_in_same_query!( | allow_tables_to_appear_in_same_query!( | ||||||
|     attachments, |     attachments, | ||||||
|  | @ -361,6 +380,7 @@ allow_tables_to_appear_in_same_query!( | ||||||
|     org_policies, |     org_policies, | ||||||
|     organizations, |     organizations, | ||||||
|     sends, |     sends, | ||||||
|  |     sso_users, | ||||||
|     twofactor, |     twofactor, | ||||||
|     users, |     users, | ||||||
|     users_collections, |     users_collections, | ||||||
|  |  | ||||||
|  | @ -235,6 +235,7 @@ table! { | ||||||
|         uuid -> Text, |         uuid -> Text, | ||||||
|         user_uuid -> Text, |         user_uuid -> Text, | ||||||
|         org_uuid -> Text, |         org_uuid -> Text, | ||||||
|  |         invited_by_email -> Nullable<Text>, | ||||||
|         access_all -> Bool, |         access_all -> Bool, | ||||||
|         akey -> Text, |         akey -> Text, | ||||||
|         status -> Integer, |         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! { | table! { | ||||||
|     emergency_access (uuid) { |     emergency_access (uuid) { | ||||||
|         uuid -> Text, |         uuid -> Text, | ||||||
|  | @ -348,6 +366,7 @@ joinable!(collections_groups -> collections (collections_uuid)); | ||||||
| joinable!(collections_groups -> groups (groups_uuid)); | joinable!(collections_groups -> groups (groups_uuid)); | ||||||
| joinable!(event -> users_organizations (uuid)); | joinable!(event -> users_organizations (uuid)); | ||||||
| joinable!(auth_requests -> users (user_uuid)); | joinable!(auth_requests -> users (user_uuid)); | ||||||
|  | joinable!(sso_users -> users (user_uuid)); | ||||||
| 
 | 
 | ||||||
| allow_tables_to_appear_in_same_query!( | allow_tables_to_appear_in_same_query!( | ||||||
|     attachments, |     attachments, | ||||||
|  | @ -361,6 +380,7 @@ allow_tables_to_appear_in_same_query!( | ||||||
|     org_policies, |     org_policies, | ||||||
|     organizations, |     organizations, | ||||||
|     sends, |     sends, | ||||||
|  |     sso_users, | ||||||
|     twofactor, |     twofactor, | ||||||
|     users, |     users, | ||||||
|     users_collections, |     users_collections, | ||||||
|  |  | ||||||
							
								
								
									
										10
									
								
								src/error.rs
									
										
									
									
									
								
							
							
						
						
									
										10
									
								
								src/error.rs
									
										
									
									
									
								
							|  | @ -159,6 +159,10 @@ impl Error { | ||||||
|     pub fn get_event(&self) -> &Option<ErrorEvent> { |     pub fn get_event(&self) -> &Option<ErrorEvent> { | ||||||
|         &self.event |         &self.event | ||||||
|     } |     } | ||||||
|  | 
 | ||||||
|  |     pub fn message(&self) -> &str { | ||||||
|  |         &self.message | ||||||
|  |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| pub trait MapResult<S> { | pub trait MapResult<S> { | ||||||
|  | @ -278,9 +282,15 @@ macro_rules! err_silent { | ||||||
|     ($msg:expr) => {{ |     ($msg:expr) => {{ | ||||||
|         return Err($crate::error::Error::new($msg, $msg)); |         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) => {{ |     ($usr_msg:expr, $log_value:expr) => {{ | ||||||
|         return Err($crate::error::Error::new($usr_msg, $log_value)); |         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] | #[macro_export] | ||||||
|  |  | ||||||
							
								
								
									
										18
									
								
								src/mail.rs
									
										
									
									
									
								
							
							
						
						
									
										18
									
								
								src/mail.rs
									
										
									
									
									
								
							|  | @ -301,7 +301,11 @@ pub async fn send_invite( | ||||||
|             .append_pair("organizationId", &org_id) |             .append_pair("organizationId", &org_id) | ||||||
|             .append_pair("organizationUserId", &member_id) |             .append_pair("organizationUserId", &member_id) | ||||||
|             .append_pair("token", &invite_token); |             .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"); |             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 |     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 { | pub async fn send_test(address: &str) -> EmptyResult { | ||||||
|     let (subject, body_html, body_text) = get_text( |     let (subject, body_html, body_text) = get_text( | ||||||
|         "email/smtp_test", |         "email/smtp_test", | ||||||
|  |  | ||||||
|  | @ -56,6 +56,8 @@ mod db; | ||||||
| mod http_client; | mod http_client; | ||||||
| mod mail; | mod mail; | ||||||
| mod ratelimit; | mod ratelimit; | ||||||
|  | mod sso; | ||||||
|  | mod sso_client; | ||||||
| mod util; | mod util; | ||||||
| 
 | 
 | ||||||
| use crate::api::core::two_factor::duo_oidc::purge_duo_contexts; | 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
 |             // 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
 |             // jobs that run more often than once a minute, so a default poll
 | ||||||
|             // interval of 30 seconds should be sufficient. Users who want to
 |             // interval of 30 seconds should be sufficient. Users who want to
 | ||||||
|  |  | ||||||
							
								
								
									
										462
									
								
								src/sso.rs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										462
									
								
								src/sso.rs
									
										
									
									
									
										Normal file
									
								
							|  | @ -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
									
								
								src/sso_client.rs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										264
									
								
								src/sso_client.rs
									
										
									
									
									
										Normal file
									
								
							|  | @ -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(®ex_str) { | ||||||
|  |                 Ok(regex) => { | ||||||
|  |                     verifier = verifier.set_other_audience_verifier_fn(move |aud| regex.is_match(aud)); | ||||||
|  |                 } | ||||||
|  |                 Err(err) => { | ||||||
|  |                     error!("Failed to parse SSO_AUDIENCE_TRUSTED={regex_str} regex: {err}"); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         verifier | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     pub async fn exchange_refresh_token( | ||||||
|  |         refresh_token: String, | ||||||
|  |     ) -> ApiResult<(Option<String>, 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 | ||||||
|  |     } | ||||||
|  | } | ||||||
Some files were not shown because too many files have changed in this diff Show more
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue