From 10944ded2fb92c5350c2a1892da19abf52faa90c Mon Sep 17 00:00:00 2001 From: Felix Eckhofer Date: Wed, 30 Aug 2023 16:13:09 +0200 Subject: [PATCH 01/10] Add SSO functionality using OpenID Connect Co-authored-by: Pablo Ovelleiro Corral Co-authored-by: Stuart Heap Co-authored-by: Alex Moore Co-authored-by: Brian Munro Co-authored-by: Jacques B. --- .env.template | 14 + Cargo.toml | 3 + .../mysql/2023-02-01-133000_add_sso/down.sql | 1 + .../mysql/2023-02-01-133000_add_sso/up.sql | 3 + .../2023-02-01-133000_add_sso/down.sql | 1 + .../2023-02-01-133000_add_sso/up.sql | 3 + .../sqlite/2023-02-01-133000_add_sso/down.sql | 1 + .../sqlite/2023-02-01-133000_add_sso/up.sql | 3 + src/api/core/accounts.rs | 73 +++- src/api/core/organizations.rs | 37 ++ src/api/identity.rs | 349 +++++++++++++++++- src/auth.rs | 23 ++ src/config.rs | 29 ++ src/db/models/mod.rs | 2 + src/db/models/org_policy.rs | 2 +- src/db/models/organization.rs | 8 +- src/db/models/sso_nonce.rs | 60 +++ src/db/schemas/mysql/schema.rs | 6 + src/db/schemas/postgresql/schema.rs | 6 + src/db/schemas/sqlite/schema.rs | 6 + src/mail.rs | 12 + src/static/templates/email/set_password.hbs | 6 + .../templates/email/set_password.html.hbs | 11 + src/util.rs | 58 ++- 24 files changed, 702 insertions(+), 15 deletions(-) create mode 100644 migrations/mysql/2023-02-01-133000_add_sso/down.sql create mode 100644 migrations/mysql/2023-02-01-133000_add_sso/up.sql create mode 100644 migrations/postgresql/2023-02-01-133000_add_sso/down.sql create mode 100644 migrations/postgresql/2023-02-01-133000_add_sso/up.sql create mode 100644 migrations/sqlite/2023-02-01-133000_add_sso/down.sql create mode 100644 migrations/sqlite/2023-02-01-133000_add_sso/up.sql create mode 100644 src/db/models/sso_nonce.rs create mode 100644 src/static/templates/email/set_password.hbs create mode 100644 src/static/templates/email/set_password.html.hbs diff --git a/.env.template b/.env.template index 62ce52585d..37ee9a4d02 100644 --- a/.env.template +++ b/.env.template @@ -435,6 +435,20 @@ ## Setting this to true will enforce the Single Org Policy to be enabled before you can enable the Reset Password policy. # ENFORCE_SINGLE_ORG_WITH_RESET_PW_POLICY=false +##################################### +### SSO settings (OpenID Connect) ### +##################################### + +## Controls whether users can login using an OpenID Connect identity provider +# SSO_ENABLED=false +## Prevent users from logging in directly without going through SSO +# SSO_ONLY=false +## Base URL of the OIDC server (auto-discovery is used) +# SSO_AUTHORITY=https://auth.example.com +## Set your Client ID and Client Key +# SSO_CLIENT_ID=11111 +# SSO_CLIENT_SECRET=AAAAAAAAAAAAAAAAAAAAAAAA + ######################## ### MFA/2FA settings ### ######################## diff --git a/Cargo.toml b/Cargo.toml index 150b3b9ddd..69ba72c1ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -149,6 +149,9 @@ pico-args = "0.5.0" paste = "1.0.15" governor = "0.7.0" +# OIDC for SSO +openidconnect = "3.4.0" + # Check client versions for specific features. semver = "1.0.23" diff --git a/migrations/mysql/2023-02-01-133000_add_sso/down.sql b/migrations/mysql/2023-02-01-133000_add_sso/down.sql new file mode 100644 index 0000000000..2c946dc512 --- /dev/null +++ b/migrations/mysql/2023-02-01-133000_add_sso/down.sql @@ -0,0 +1 @@ +DROP TABLE sso_nonce; diff --git a/migrations/mysql/2023-02-01-133000_add_sso/up.sql b/migrations/mysql/2023-02-01-133000_add_sso/up.sql new file mode 100644 index 0000000000..c10ab5cfe6 --- /dev/null +++ b/migrations/mysql/2023-02-01-133000_add_sso/up.sql @@ -0,0 +1,3 @@ +CREATE TABLE sso_nonce ( + nonce CHAR(36) NOT NULL PRIMARY KEY +); diff --git a/migrations/postgresql/2023-02-01-133000_add_sso/down.sql b/migrations/postgresql/2023-02-01-133000_add_sso/down.sql new file mode 100644 index 0000000000..2c946dc512 --- /dev/null +++ b/migrations/postgresql/2023-02-01-133000_add_sso/down.sql @@ -0,0 +1 @@ +DROP TABLE sso_nonce; diff --git a/migrations/postgresql/2023-02-01-133000_add_sso/up.sql b/migrations/postgresql/2023-02-01-133000_add_sso/up.sql new file mode 100644 index 0000000000..57f976c152 --- /dev/null +++ b/migrations/postgresql/2023-02-01-133000_add_sso/up.sql @@ -0,0 +1,3 @@ +CREATE TABLE sso_nonce ( + nonce CHAR(36) NOT NULL PRIMARY KEY +); \ No newline at end of file diff --git a/migrations/sqlite/2023-02-01-133000_add_sso/down.sql b/migrations/sqlite/2023-02-01-133000_add_sso/down.sql new file mode 100644 index 0000000000..2c946dc512 --- /dev/null +++ b/migrations/sqlite/2023-02-01-133000_add_sso/down.sql @@ -0,0 +1 @@ +DROP TABLE sso_nonce; diff --git a/migrations/sqlite/2023-02-01-133000_add_sso/up.sql b/migrations/sqlite/2023-02-01-133000_add_sso/up.sql new file mode 100644 index 0000000000..c10ab5cfe6 --- /dev/null +++ b/migrations/sqlite/2023-02-01-133000_add_sso/up.sql @@ -0,0 +1,3 @@ +CREATE TABLE sso_nonce ( + nonce CHAR(36) NOT NULL PRIMARY KEY +); diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index 87e445291f..172956862a 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -33,6 +33,7 @@ pub fn routes() -> Vec { get_public_keys, post_keys, post_password, + post_set_password, post_kdf, post_rotatekey, post_sstamp, @@ -82,6 +83,21 @@ pub struct RegisterData { organization_user_id: Option, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SetPasswordData { + kdf: Option, + kdf_iterations: Option, + kdf_memory: Option, + kdf_parallelism: Option, + key: String, + keys: Option, + master_password_hash: String, + master_password_hint: Option, + #[allow(dead_code)] + org_identifier: Option, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct KeysData { @@ -89,6 +105,13 @@ struct KeysData { public_key: String, } +#[derive(Debug, Serialize, Deserialize)] +struct TokenPayload { + exp: i64, + email: String, + nonce: String, +} + /// Trims whitespace from password hints, and converts blank password hints to `None`. fn clean_password_hint(password_hint: &Option) -> Option { match password_hint { @@ -244,6 +267,50 @@ pub async fn _register(data: Json, mut conn: DbConn) -> JsonResult }))) } +#[post("/accounts/set-password", data = "")] +async fn post_set_password(data: Json, headers: Headers, mut conn: DbConn) -> JsonResult { + let data: SetPasswordData = data.into_inner(); + let mut user = headers.user; + + // 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_hash); + enforce_password_hint_setting(&password_hint)?; + + if let Some(client_kdf_iter) = data.kdf_iterations { + user.client_kdf_iter = client_kdf_iter; + } + + if let Some(client_kdf_type) = data.kdf { + user.client_kdf_type = client_kdf_type; + } + + // We need to allow revision-date to use the old security_timestamp + let routes = ["revision_date"]; + let routes: Option> = Some(routes.iter().map(ToString::to_string).collect()); + + user.client_kdf_memory = data.kdf_memory; + user.client_kdf_parallelism = data.kdf_parallelism; + + user.set_password(&data.master_password_hash, Some(data.key), false, routes); + user.password_hint = password_hint; + + if let Some(keys) = data.keys { + user.private_key = Some(keys.encrypted_private_key); + user.public_key = Some(keys.public_key); + } + + if CONFIG.mail_enabled() { + mail::send_set_password(&user.email.to_lowercase(), &user.name).await?; + } + + user.save(&mut conn).await?; + Ok(Json(json!({ + "Object": "set-password", + "CaptchaBypassToken": "", + }))) +} + #[get("/accounts/profile")] async fn profile(headers: Headers, mut conn: DbConn) -> Json { Json(headers.user.to_json(&mut conn).await) @@ -980,7 +1047,7 @@ struct SecretVerificationRequest { } #[post("/accounts/verify-password", data = "")] -fn verify_password(data: Json, headers: Headers) -> EmptyResult { +fn verify_password(data: Json, headers: Headers) -> JsonResult { let data: SecretVerificationRequest = data.into_inner(); let user = headers.user; @@ -988,7 +1055,9 @@ fn verify_password(data: Json, headers: Headers) -> E err!("Invalid password") } - Ok(()) + Ok(Json(json!({ + "MasterPasswordPolicy": {}, // Required for SSO login with mobile apps + }))) } async fn _api_key(data: Json, rotate: bool, headers: Headers, mut conn: DbConn) -> JsonResult { diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 2bff64b847..a4b52fb576 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -41,6 +41,7 @@ pub fn routes() -> Vec { bulk_delete_organization_collections, post_bulk_collections, get_org_details, + get_org_domain_sso_details, get_org_users, send_invite, reinvite_user, @@ -57,6 +58,7 @@ pub fn routes() -> Vec { post_org_import, list_policies, list_policies_token, + list_policies_invited_user, get_policy, put_policy, get_organization_tax, @@ -98,6 +100,7 @@ pub fn routes() -> Vec { get_org_export, api_key, rotate_api_key, + get_auto_enroll_status, ] } @@ -305,6 +308,13 @@ async fn get_user_collections(headers: Headers, mut conn: DbConn) -> Json })) } +#[get("/organizations/<_identifier>/auto-enroll-status")] +fn get_auto_enroll_status(_identifier: String) -> JsonResult { + Ok(Json(json!({ + "ResetPasswordEnabled": false, // Not implemented + }))) +} + #[get("/organizations//collections")] async fn get_org_collections(org_id: &str, _headers: ManagerHeadersLoose, mut conn: DbConn) -> Json { Json(json!({ @@ -780,6 +790,14 @@ async fn _get_org_details(org_id: &str, host: &str, user_uuid: &str, conn: &mut json!(ciphers_json) } +#[post("/organizations/domain/sso/details")] +fn get_org_domain_sso_details() -> JsonResult { + Ok(Json(json!({ + "organizationIdentifier": "vaultwarden", + "ssoAvailable": CONFIG.sso_enabled() + }))) +} + #[derive(FromForm)] struct GetOrgUserData { #[field(name = "includeCollections")] @@ -1745,6 +1763,25 @@ async fn list_policies_token(org_id: &str, token: &str, mut conn: DbConn) -> Jso }))) } +#[allow(non_snake_case)] +#[get("/organizations//policies/invited-user?")] +async fn list_policies_invited_user(org_id: String, userId: String, mut conn: DbConn) -> JsonResult { + // We should confirm the user is part of the organization, but unique domain_hints must be supported first. + + if userId.is_empty() { + err!("userId must not be empty"); + } + + let policies = OrgPolicy::find_by_org(&org_id, &mut conn).await; + let policies_json: Vec = policies.iter().map(OrgPolicy::to_json).collect(); + + Ok(Json(json!({ + "Data": policies_json, + "Object": "list", + "ContinuationToken": null + }))) +} + #[get("/organizations//policies/")] async fn get_policy(org_id: &str, pol_type: i32, _headers: AdminHeaders, mut conn: DbConn) -> JsonResult { let pol_type_enum = match OrgPolicyType::from_i32(pol_type) { diff --git a/src/api/identity.rs b/src/api/identity.rs index 445d61fdf9..b1d0cc16a4 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -1,8 +1,10 @@ use chrono::Utc; +use jsonwebtoken::DecodingKey; use num_traits::FromPrimitive; use rocket::serde::json::Json; use rocket::{ form::{Form, FromForm}, + http::CookieJar, Route, }; use serde_json::Value; @@ -17,14 +19,16 @@ use crate::{ push::register_push_device, ApiResult, EmptyResult, JsonResult, }, - auth::{generate_organization_api_key_login_claims, ClientHeaders, ClientIp}, + auth::{encode_jwt, generate_organization_api_key_login_claims, generate_ssotoken_claims, ClientHeaders, ClientIp}, db::{models::*, DbConn}, error::MapResult, - mail, util, CONFIG, + mail, util, + util::{CookieManager, CustomRedirect}, + CONFIG, }; pub fn routes() -> Vec { - routes![login, prelogin, identity_register] + routes![login, prelogin, identity_register, prevalidate, authorize, oidcsignin] } #[post("/connect/token", data = "")] @@ -61,6 +65,15 @@ async fn login(data: Form, client_header: ClientHeaders, mut conn: _api_key_login(data, &mut user_uuid, &mut conn, &client_header.ip).await } + "authorization_code" => { + _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")?; + _authorization_login(data, &mut user_uuid, &mut conn, &client_header.ip).await + } t => err!("Invalid type", t), }; @@ -127,6 +140,141 @@ async fn _refresh_login(data: ConnectData, conn: &mut DbConn) -> JsonResult { Ok(Json(result)) } +#[derive(Debug, Serialize, Deserialize)] +struct TokenPayload { + exp: i64, + email: Option, + nonce: String, +} + +async fn _authorization_login( + data: ConnectData, + user_uuid: &mut Option, + conn: &mut DbConn, + ip: &ClientIp, +) -> JsonResult { + let scope = match data.scope.as_ref() { + None => err!("Got no scope in OIDC data"), + Some(scope) => scope, + }; + if scope != "api offline_access" { + err!("Scope not supported") + } + + let scope_vec = vec!["api".into(), "offline_access".into()]; + let code = match data.code.as_ref() { + None => err!("Got no code in OIDC data"), + Some(code) => code, + }; + + let (refresh_token, id_token, user_info) = match get_auth_code_access_token(code).await { + Ok((refresh_token, id_token, user_info)) => (refresh_token, id_token, user_info), + Err(_err) => err!("Could not retrieve access token"), + }; + + let mut validation = jsonwebtoken::Validation::default(); + validation.insecure_disable_signature_validation(); + + let token = + match jsonwebtoken::decode::(id_token.as_str(), &DecodingKey::from_secret(&[]), &validation) { + Err(_err) => err!("Could not decode id token"), + Ok(payload) => payload.claims, + }; + + // let expiry = token.exp; + let nonce = token.nonce; + let mut new_user = false; + + match SsoNonce::find(&nonce, conn).await { + Some(sso_nonce) => { + match sso_nonce.delete(conn).await { + Ok(_) => { + let user_email = match token.email { + Some(email) => email, + None => match user_info.email() { + None => err!("Neither id token nor userinfo contained an email"), + Some(email) => email.to_owned().to_string(), + }, + }; + let now = Utc::now().naive_utc(); + + let mut user = match User::find_by_mail(&user_email, conn).await { + Some(user) => user, + None => { + new_user = true; + User::new(user_email.clone()) + } + }; + + if new_user { + user.verified_at = Some(Utc::now().naive_utc()); + user.save(conn).await?; + } + + // Set the user_uuid here to be passed back used for event logging. + *user_uuid = Some(user.uuid.clone()); + + let (mut device, new_device) = get_device(&data, conn, &user).await; + + let twofactor_token = twofactor_auth(&user, &data, &mut device, ip, true, conn).await?; + + if CONFIG.mail_enabled() && new_device { + if let Err(e) = + mail::send_new_device_logged_in(&user.email, &ip.ip.to_string(), &now, &device.name).await + { + error!("Error sending new device email: {:#?}", e); + + if CONFIG.require_device_email() { + err!("Could not send login notification email. Please contact your administrator.") + } + } + } + + if CONFIG.sso_acceptall_invites() { + for user_org in UserOrganization::find_invited_by_user(&user.uuid, conn).await.iter_mut() { + user_org.status = UserOrgStatus::Accepted as i32; + user_org.save(conn).await?; + } + } + + device.refresh_token = refresh_token.clone(); + device.save(conn).await?; + + let (access_token, expires_in) = device.refresh_tokens(&user, scope_vec); + device.save(conn).await?; + + let mut result = json!({ + "access_token": access_token, + "token_type": "Bearer", + "refresh_token": device.refresh_token, + "expires_in": expires_in, + "Key": user.akey, + "PrivateKey": user.private_key, + "Kdf": user.client_kdf_type, + "KdfIterations": user.client_kdf_iter, + "KdfMemory": user.client_kdf_memory, + "KdfParallelism": user.client_kdf_parallelism, + "ResetMasterPassword": user.password_hash.is_empty(), + "scope": scope, + "unofficialServer": true, + }); + + if let Some(token) = twofactor_token { + result["TwoFactorToken"] = Value::String(token); + } + + info!("User {} logged in successfully. IP: {}", user.email, ip.ip); + Ok(Json(result)) + } + Err(_) => err!("Failed to delete nonce"), + } + } + None => { + err!("Invalid nonce") + } + } +} + #[derive(Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] struct MasterPasswordPolicy { @@ -155,6 +303,10 @@ async fn _password_login( // Ratelimit the login crate::ratelimit::check_limit_login(&ip.ip)?; + if CONFIG.sso_enabled() && CONFIG.sso_only() { + err!("SSO sign-in is required"); + } + // Get the user let username = data.username.as_ref().unwrap().trim(); let mut user = match User::find_by_mail(username, conn).await { @@ -263,7 +415,7 @@ async fn _password_login( let (mut device, new_device) = get_device(&data, conn, &user).await; - let twofactor_token = twofactor_auth(&user, &data, &mut device, ip, conn).await?; + let twofactor_token = twofactor_auth(&user, &data, &mut device, ip, false, conn).await?; if CONFIG.mail_enabled() && new_device { if let Err(e) = mail::send_new_device_logged_in(&user.email, &ip.ip.to_string(), &now, &device).await { @@ -523,6 +675,7 @@ async fn twofactor_auth( data: &ConnectData, device: &mut Device, ip: &ClientIp, + is_sso: bool, conn: &mut DbConn, ) -> ApiResult> { let twofactors = TwoFactor::find_by_user(&user.uuid, conn).await; @@ -541,7 +694,15 @@ async fn twofactor_auth( let twofactor_code = match data.two_factor_token { Some(ref code) => code, None => { - err_json!(_json_err_twofactor(&twofactor_ids, &user.uuid, data, conn).await?, "2FA token not provided") + if is_sso { + if CONFIG.sso_only() { + err!("2FA not supported with SSO login, contact your administrator"); + } else { + err!("2FA not supported with SSO login, log in directly using email and master password"); + } + } else { + err_json!(_json_err_twofactor(&twofactor_ids, &user.uuid, data, conn).await?, "2FA token not provided"); + } } }; @@ -775,11 +936,187 @@ struct ConnectData { two_factor_remember: Option, #[field(name = uncased("authrequest"))] auth_request: Option, + // Needed for authorization code + #[form(field = uncased("code"))] + code: Option, } - fn _check_is_some(value: &Option, msg: &str) -> EmptyResult { if value.is_none() { err!(msg) } Ok(()) } + +#[get("/account/prevalidate")] +#[allow(non_snake_case)] +fn prevalidate() -> JsonResult { + let claims = generate_ssotoken_claims(); + let ssotoken = encode_jwt(&claims); + Ok(Json(json!({ + "token": ssotoken, + }))) +} + +use openidconnect::core::{CoreClient, CoreProviderMetadata, CoreResponseType, CoreUserInfoClaims}; +use openidconnect::reqwest::async_http_client; +use openidconnect::{ + AuthenticationFlow, AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, Nonce, OAuth2TokenResponse, + RedirectUrl, Scope, +}; + +async fn get_client_from_sso_config() -> ApiResult { + let redirect = CONFIG.sso_callback_path(); + let client_id = ClientId::new(CONFIG.sso_client_id()); + let client_secret = ClientSecret::new(CONFIG.sso_client_secret()); + let issuer_url = match IssuerUrl::new(CONFIG.sso_authority()) { + Ok(issuer) => issuer, + Err(_err) => err!("invalid issuer URL"), + }; + + let provider_metadata = match CoreProviderMetadata::discover_async(issuer_url, async_http_client).await { + Ok(metadata) => metadata, + Err(_err) => { + err!("Failed to discover OpenID provider") + } + }; + + let redirect_uri = match RedirectUrl::new(redirect) { + Ok(uri) => uri, + Err(err) => err!("Invalid redirection url: {}", err.to_string()), + }; + let client = CoreClient::from_provider_metadata(provider_metadata, client_id, Some(client_secret)) + .set_redirect_uri(redirect_uri); + + Ok(client) +} + +#[get("/connect/oidc-signin?")] +fn oidcsignin(code: String, jar: &CookieJar<'_>, _conn: DbConn) -> ApiResult { + let cookiemanager = CookieManager::new(jar); + + let redirect_uri = match cookiemanager.get_cookie("redirect_uri".to_string()) { + None => err!("No redirect_uri in cookie"), + Some(uri) => uri, + }; + let orig_state = match cookiemanager.get_cookie("state".to_string()) { + None => err!("No state in cookie"), + Some(state) => state, + }; + + cookiemanager.delete_cookie("redirect_uri".to_string()); + cookiemanager.delete_cookie("state".to_string()); + + let redirect = CustomRedirect { + url: format!("{redirect_uri}?code={code}&state={orig_state}"), + headers: vec![], + }; + + Ok(redirect) +} + +#[derive(FromForm)] +#[allow(non_snake_case)] +struct AuthorizeData { + #[allow(unused)] + #[field(name = uncased("client_id"))] + #[field(name = uncased("clientid"))] + client_id: Option, + #[field(name = uncased("redirect_uri"))] + #[field(name = uncased("redirecturi"))] + redirect_uri: Option, + #[allow(unused)] + #[field(name = uncased("response_type"))] + #[field(name = uncased("responsetype"))] + response_type: Option, + #[allow(unused)] + #[field(name = uncased("scope"))] + scope: Option, + #[field(name = uncased("state"))] + state: Option, + #[allow(unused)] + #[field(name = uncased("code_challenge"))] + code_challenge: Option, + #[allow(unused)] + #[field(name = uncased("code_challenge_method"))] + code_challenge_method: Option, + #[allow(unused)] + #[field(name = uncased("response_mode"))] + response_mode: Option, + #[allow(unused)] + #[field(name = uncased("domain_hint"))] + domain_hint: Option, + #[allow(unused)] + #[field(name = uncased("ssoToken"))] + ssoToken: Option, +} + +#[get("/connect/authorize?")] +async fn authorize(data: AuthorizeData, jar: &CookieJar<'_>, mut conn: DbConn) -> ApiResult { + let cookiemanager = CookieManager::new(jar); + match get_client_from_sso_config().await { + Ok(client) => { + let (auth_url, _csrf_state, nonce) = client + .authorize_url( + AuthenticationFlow::::AuthorizationCode, + CsrfToken::new_random, + Nonce::new_random, + ) + .add_scope(Scope::new("email".to_string())) + .add_scope(Scope::new("profile".to_string())) + .url(); + + let sso_nonce = SsoNonce::new(nonce.secret().to_string()); + sso_nonce.save(&mut conn).await?; + + let redirect_uri = match data.redirect_uri { + None => err!("No redirect_uri in data"), + Some(uri) => uri, + }; + cookiemanager.set_cookie("redirect_uri".to_string(), redirect_uri); + let state = match data.state { + None => err!("No state in data"), + Some(state) => state, + }; + cookiemanager.set_cookie("state".to_string(), state); + + let redirect = CustomRedirect { + url: format!("{}", auth_url), + headers: vec![], + }; + + Ok(redirect) + } + Err(_err) => err!("Unable to find client from identifier"), + } +} + +async fn get_auth_code_access_token(code: &str) -> ApiResult<(String, String, CoreUserInfoClaims)> { + let oidc_code = AuthorizationCode::new(String::from(code)); + match get_client_from_sso_config().await { + Ok(client) => match client.exchange_code(oidc_code).request_async(async_http_client).await { + Ok(token_response) => { + let refresh_token = match token_response.refresh_token() { + Some(token) => token.secret().to_string(), + None => String::new(), + }; + let id_token = match token_response.extra_fields().id_token() { + None => err!("Token response did not contain an id_token"), + Some(token) => token.to_string(), + }; + + let user_info: CoreUserInfoClaims = + match client.user_info(token_response.access_token().to_owned(), None) { + Err(_err) => err!("Token response did not contain user_info"), + Ok(info) => match info.request_async(async_http_client).await { + Err(_err) => err!("Request to user_info endpoint failed"), + Ok(claim) => claim, + }, + }; + + Ok((refresh_token, id_token, user_info)) + } + Err(err) => err!("Failed to contact token endpoint: {}", err.to_string()), + }, + Err(_err) => err!("Unable to find client"), + } +} diff --git a/src/auth.rs b/src/auth.rs index 809ef9fd90..f07a9461e5 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -25,6 +25,7 @@ pub static JWT_LOGIN_ISSUER: Lazy = Lazy::new(|| format!("{}|login", CON static JWT_INVITE_ISSUER: Lazy = Lazy::new(|| format!("{}|invite", CONFIG.domain_origin())); static JWT_EMERGENCY_ACCESS_INVITE_ISSUER: Lazy = Lazy::new(|| format!("{}|emergencyaccessinvite", CONFIG.domain_origin())); +static JWT_SSOTOKEN_ISSUER: Lazy = Lazy::new(|| format!("{}|ssotoken", CONFIG.domain_origin())); static JWT_DELETE_ISSUER: Lazy = Lazy::new(|| format!("{}|delete", CONFIG.domain_origin())); static JWT_VERIFYEMAIL_ISSUER: Lazy = Lazy::new(|| format!("{}|verifyemail", CONFIG.domain_origin())); static JWT_ADMIN_ISSUER: Lazy = Lazy::new(|| format!("{}|admin", CONFIG.domain_origin())); @@ -331,6 +332,28 @@ pub fn generate_delete_claims(uuid: String) -> BasicJwtClaims { } } +#[derive(Debug, Serialize, Deserialize)] +pub struct SsoTokenJwtClaims { + // Not before + pub nbf: i64, + // Expiration time + pub exp: i64, + // Issuer + pub iss: String, + // Subject + pub sub: String, +} + +pub fn generate_ssotoken_claims() -> SsoTokenJwtClaims { + let time_now = Utc::now().naive_utc(); + SsoTokenJwtClaims { + nbf: time_now.timestamp(), + exp: (time_now + Duration::minutes(2)).timestamp(), + iss: JWT_SSOTOKEN_ISSUER.to_string(), + sub: "vaultwarden".to_string(), + } +} + pub fn generate_verify_email_claims(uuid: String) -> BasicJwtClaims { let time_now = Utc::now(); let expire_hours = i64::from(CONFIG.invitation_expiration_hours()); diff --git a/src/config.rs b/src/config.rs index e4e8092757..20aebd9aa0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -637,6 +637,24 @@ make_config! { enforce_single_org_with_reset_pw_policy: bool, false, def, false; }, + /// OpenID Connect SSO settings + sso { + /// Enabled + sso_enabled: bool, true, def, false; + /// Force SSO login + sso_only: bool, true, def, false; + /// Client ID + sso_client_id: String, true, def, String::new(); + /// Client Key + sso_client_secret: Pass, true, def, String::new(); + /// Authority Server + sso_authority: String, true, def, String::new(); + /// CallBack Path + sso_callback_path: String, false, gen, |c| generate_sso_callback_path(&c.domain); + /// Allow workaround so SSO logins accept all invites + sso_acceptall_invites: bool, true, def, false; + }, + /// Yubikey settings yubico: _enable_yubico { /// Enabled @@ -855,6 +873,12 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> { err!("All Duo options need to be set for global Duo support") } + if cfg.sso_enabled + && (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") + } + if cfg._enable_yubico { if cfg.yubico_client_id.is_some() != cfg.yubico_secret_key.is_some() { err!("Both `YUBICO_CLIENT_ID` and `YUBICO_SECRET_KEY` must be set for Yubikey OTP support") @@ -1063,6 +1087,10 @@ fn generate_smtp_img_src(embed_images: bool, domain: &str) -> String { } } +fn generate_sso_callback_path(domain: &str) -> String { + format!("{domain}/identity/connect/oidc-signin") +} + /// Generate the correct URL for the icon service. /// This will be used within icons.rs to call the external icon service. fn generate_icon_service_url(icon_service: &str) -> String { @@ -1357,6 +1385,7 @@ where reg!("email/send_emergency_access_invite", ".html"); reg!("email/send_org_invite", ".html"); reg!("email/send_single_org_removed_from_org", ".html"); + reg!("email/set_password", ".html"); reg!("email/smtp_test", ".html"); reg!("email/twofactor_email", ".html"); reg!("email/verify_email", ".html"); diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index c336cb1ad5..72afcd750f 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -11,6 +11,7 @@ mod group; mod org_policy; mod organization; mod send; +mod sso_nonce; mod two_factor; mod two_factor_duo_context; mod two_factor_incomplete; @@ -29,6 +30,7 @@ pub use self::group::{CollectionGroup, Group, GroupUser}; pub use self::org_policy::{OrgPolicy, OrgPolicyErr, OrgPolicyType}; pub use self::organization::{Organization, OrganizationApiKey, UserOrgStatus, UserOrgType, UserOrganization}; pub use self::send::{Send, SendType}; +pub use self::sso_nonce::SsoNonce; pub use self::two_factor::{TwoFactor, TwoFactorType}; pub use self::two_factor_duo_context::TwoFactorDuoContext; pub use self::two_factor_incomplete::TwoFactorIncomplete; diff --git a/src/db/models/org_policy.rs b/src/db/models/org_policy.rs index 23e583b408..935e4bc38b 100644 --- a/src/db/models/org_policy.rs +++ b/src/db/models/org_policy.rs @@ -27,7 +27,7 @@ pub enum OrgPolicyType { MasterPassword = 1, PasswordGenerator = 2, SingleOrg = 3, - // RequireSso = 4, // Not supported + RequireSso = 4, PersonalOwnership = 5, DisableSend = 6, SendOptions = 7, diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index 15f009918f..c42050ff9f 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -170,9 +170,9 @@ impl Organization { "useGroups": CONFIG.org_groups_enabled(), "useTotp": true, "usePolicies": true, - // "useScim": false, // Not supported (Not AGPLv3 Licensed) - "useSso": false, // Not supported - // "useKeyConnector": false, // Not supported + // "UseScim": false, // Not supported (Not AGPLv3 Licensed) + "useSso": CONFIG.sso_enabled(), + // "UseKeyConnector": false, // Not supported "selfHost": true, "useApi": true, "hasPublicAndPrivateKeys": self.private_key.is_some() && self.public_key.is_some(), @@ -396,7 +396,7 @@ impl UserOrganization { "resetPasswordEnrolled": self.reset_password_key.is_some(), "useResetPassword": CONFIG.mail_enabled(), "ssoBound": false, // Not supported - "useSso": false, // Not supported + "useSso": CONFIG.sso_enabled(), "useKeyConnector": false, "useSecretsManager": false, "usePasswordManager": true, diff --git a/src/db/models/sso_nonce.rs b/src/db/models/sso_nonce.rs new file mode 100644 index 0000000000..0a9533e032 --- /dev/null +++ b/src/db/models/sso_nonce.rs @@ -0,0 +1,60 @@ +use crate::api::EmptyResult; +use crate::db::DbConn; +use crate::error::MapResult; + +db_object! { + #[derive(Identifiable, Queryable, Insertable)] + #[diesel(table_name = sso_nonce)] + #[diesel(primary_key(nonce))] + pub struct SsoNonce { + pub nonce: String, + } +} + +/// Local methods +impl SsoNonce { + pub fn new(nonce: String) -> Self { + Self { + nonce, + } + } +} + +/// 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 device") + } + 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(self, conn: &mut DbConn) -> EmptyResult { + db_run! { conn: { + diesel::delete(sso_nonce::table.filter(sso_nonce::nonce.eq(self.nonce))) + .execute(conn) + .map_res("Error deleting SSO nonce") + }} + } + + pub async fn find(nonce: &str, conn: &mut DbConn) -> Option { + db_run! { conn: { + sso_nonce::table + .filter(sso_nonce::nonce.eq(nonce)) + .first::(conn) + .ok() + .from_db() + }} + } +} diff --git a/src/db/schemas/mysql/schema.rs b/src/db/schemas/mysql/schema.rs index fa84ed05bd..70cc596064 100644 --- a/src/db/schemas/mysql/schema.rs +++ b/src/db/schemas/mysql/schema.rs @@ -253,6 +253,12 @@ table! { } } +table! { + sso_nonce (nonce) { + nonce -> Text, + } +} + table! { emergency_access (uuid) { uuid -> Text, diff --git a/src/db/schemas/postgresql/schema.rs b/src/db/schemas/postgresql/schema.rs index d1ea4b02b5..1761858d41 100644 --- a/src/db/schemas/postgresql/schema.rs +++ b/src/db/schemas/postgresql/schema.rs @@ -253,6 +253,12 @@ table! { } } +table! { + sso_nonce (nonce) { + nonce -> Text, + } +} + table! { emergency_access (uuid) { uuid -> Text, diff --git a/src/db/schemas/sqlite/schema.rs b/src/db/schemas/sqlite/schema.rs index d1ea4b02b5..1761858d41 100644 --- a/src/db/schemas/sqlite/schema.rs +++ b/src/db/schemas/sqlite/schema.rs @@ -253,6 +253,12 @@ table! { } } +table! { + sso_nonce (nonce) { + nonce -> Text, + } +} + table! { emergency_access (uuid) { uuid -> Text, diff --git a/src/mail.rs b/src/mail.rs index 5ce4a079f9..3850ae1fc0 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -552,6 +552,18 @@ pub async fn send_change_email(address: &str, token: &str) -> EmptyResult { send_email(address, &subject, body_html, body_text).await } +pub async fn send_set_password(address: &str, user_name: &str) -> EmptyResult { + let (subject, body_html, body_text) = get_text( + "email/set_password", + json!({ + "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), + "user_name": user_name, + }), + )?; + send_email(address, &subject, body_html, body_text).await +} + pub async fn send_test(address: &str) -> EmptyResult { let (subject, body_html, body_text) = get_text( "email/smtp_test", diff --git a/src/static/templates/email/set_password.hbs b/src/static/templates/email/set_password.hbs new file mode 100644 index 0000000000..923c80f2ea --- /dev/null +++ b/src/static/templates/email/set_password.hbs @@ -0,0 +1,6 @@ +Master Password Has Been Changed + +The master password for {{user_name}} has been changed. If you did not initiate this request, please reach out to your administrator immediately. + +=== +{{> email/email_footer_text }} \ No newline at end of file diff --git a/src/static/templates/email/set_password.html.hbs b/src/static/templates/email/set_password.html.hbs new file mode 100644 index 0000000000..ede5da0cc3 --- /dev/null +++ b/src/static/templates/email/set_password.html.hbs @@ -0,0 +1,11 @@ +Master Password Has Been Changed + +{{> email/email_header }} + + + + +
+ The master password for {{user_name}} has been changed. If you did not initiate this request, please reach out to your administrator immediately. +
+{{> email/email_footer }} \ No newline at end of file diff --git a/src/util.rs b/src/util.rs index 1f3ba9cfdd..1b77040bec 100644 --- a/src/util.rs +++ b/src/util.rs @@ -6,7 +6,7 @@ use std::{collections::HashMap, io::Cursor, ops::Deref, path::Path}; use num_traits::ToPrimitive; use rocket::{ fairing::{Fairing, Info, Kind}, - http::{ContentType, Header, HeaderMap, Method, Status}, + http::{ContentType, Cookie, CookieJar, Header, HeaderMap, Method, SameSite, Status}, request::FromParam, response::{self, Responder}, Data, Orbit, Request, Response, Rocket, @@ -130,8 +130,9 @@ impl Cors { fn get_allowed_origin(headers: &HeaderMap<'_>) -> Option { let origin = Cors::get_header(headers, "Origin"); let domain_origin = CONFIG.domain_origin(); + let sso_origin = CONFIG.sso_authority(); let safari_extension_origin = "file://"; - if origin == domain_origin || origin == safari_extension_origin { + if origin == domain_origin || origin == safari_extension_origin || origin == sso_origin { Some(origin) } else { None @@ -256,6 +257,33 @@ impl<'r> FromParam<'r> for SafeString { } } +pub struct CustomRedirect { + pub url: String, + pub headers: Vec<(String, String)>, +} + +impl<'r> rocket::response::Responder<'r, 'static> for CustomRedirect { + fn respond_to(self, _: &rocket::request::Request<'_>) -> rocket::response::Result<'static> { + let mut response = Response::build() + .status(rocket::http::Status { + code: 307, + }) + .raw_header("Location", self.url) + .header(ContentType::HTML) + .finalize(); + + // Normal headers + response.set_raw_header("Referrer-Policy", "same-origin"); + response.set_raw_header("X-XSS-Protection", "0"); + + for header in &self.headers { + response.set_raw_header(header.0.clone(), header.1.clone()); + } + + Ok(response) + } +} + // Log all the routes from the main paths list, and the attachments endpoint // Effectively ignores, any static file route, and the alive endpoint const LOGGED_ROUTES: [&str; 7] = ["/api", "/admin", "/identity", "/icons", "/attachments", "/events", "/notifications"]; @@ -875,3 +903,29 @@ mod tests { }); } } + +pub struct CookieManager<'a> { + jar: &'a CookieJar<'a>, +} + +impl<'a> CookieManager<'a> { + pub fn new(jar: &'a CookieJar<'a>) -> Self { + Self { + jar, + } + } + + pub fn set_cookie(&self, name: String, value: String) { + let cookie = Cookie::build((name, value)).same_site(SameSite::Lax); + + self.jar.add(cookie) + } + + pub fn get_cookie(&self, name: String) -> Option { + self.jar.get(&name).map(|c| c.value().to_string()) + } + + pub fn delete_cookie(&self, name: String) { + self.jar.remove(Cookie::from(name)); + } +} From 6dda52a76b9388a6cfb537b2b233f5d0f4ad86d5 Mon Sep 17 00:00:00 2001 From: Timshel Date: Thu, 12 Sep 2024 15:18:39 +0200 Subject: [PATCH 02/10] Improvements and error handling --- .env.template | 24 + Cargo.lock | 681 +++++++++++++++++- Cargo.toml | 3 +- SSO.md | 286 ++++++++ docker/start.sh | 7 + .../mysql/2023-02-01-133000_add_sso/up.sql | 3 - .../down.sql | 0 .../mysql/2023-09-10-133000_add_sso/up.sql | 4 + .../down.sql | 1 + .../up.sql | 1 + .../down.sql | 6 + .../up.sql | 8 + .../down.sql | 8 + .../up.sql | 9 + .../2024-03-06-170000_add_sso_users/down.sql | 1 + .../2024-03-06-170000_add_sso_users/up.sql | 7 + .../down.sql | 0 .../up.sql | 2 + .../2023-02-01-133000_add_sso/up.sql | 3 - .../down.sql | 0 .../2023-09-10-133000_add_sso/up.sql | 4 + .../down.sql | 1 + .../up.sql | 1 + .../down.sql | 6 + .../up.sql | 8 + .../down.sql | 8 + .../up.sql | 9 + .../2024-03-06-170000_add_sso_users/down.sql | 1 + .../2024-03-06-170000_add_sso_users/up.sql | 7 + .../down.sql | 0 .../up.sql | 3 + .../sqlite/2023-02-01-133000_add_sso/up.sql | 3 - .../down.sql | 0 .../sqlite/2023-09-10-133000_add_sso/up.sql | 4 + .../down.sql | 1 + .../up.sql | 1 + .../down.sql | 6 + .../up.sql | 8 + .../down.sql | 8 + .../up.sql | 9 + .../2024-03-06-170000_add_sso_users/down.sql | 1 + .../2024-03-06-170000_add_sso_users/up.sql | 7 + .../down.sql | 0 .../2024-03-13_170000_sso_userscascade/up.sql | 9 + src/api/admin.rs | 2 +- src/api/core/accounts.rs | 40 +- src/api/core/emergency_access.rs | 2 +- src/api/core/organizations.rs | 56 +- src/api/core/public.rs | 14 +- src/api/identity.rs | 606 +++++++--------- src/api/mod.rs | 2 +- src/auth.rs | 267 ++++++- src/config.rs | 171 ++++- src/db/models/device.rs | 65 +- src/db/models/mod.rs | 2 +- src/db/models/org_policy.rs | 5 +- src/db/models/organization.rs | 23 +- src/db/models/sso_nonce.rs | 47 +- src/db/models/user.rs | 62 +- src/db/schemas/mysql/schema.rs | 16 +- src/db/schemas/postgresql/schema.rs | 16 +- src/db/schemas/sqlite/schema.rs | 16 +- src/error.rs | 4 + src/mail.rs | 18 +- src/main.rs | 8 + src/sso.rs | 544 ++++++++++++++ .../templates/email/sso_change_email.hbs | 4 + .../templates/email/sso_change_email.html.hbs | 11 + src/util.rs | 63 +- 69 files changed, 2614 insertions(+), 609 deletions(-) create mode 100644 SSO.md delete mode 100644 migrations/mysql/2023-02-01-133000_add_sso/up.sql rename migrations/mysql/{2023-02-01-133000_add_sso => 2023-09-10-133000_add_sso}/down.sql (100%) create mode 100644 migrations/mysql/2023-09-10-133000_add_sso/up.sql create mode 100644 migrations/mysql/2023-09-14-133000_add_users_organizations_invited_by_email/down.sql create mode 100644 migrations/mysql/2023-09-14-133000_add_users_organizations_invited_by_email/up.sql create mode 100644 migrations/mysql/2024-02-14-170000_add_state_to_sso_nonce/down.sql create mode 100644 migrations/mysql/2024-02-14-170000_add_state_to_sso_nonce/up.sql create mode 100644 migrations/mysql/2024-02-26-170000_add_pkce_to_sso_nonce/down.sql create mode 100644 migrations/mysql/2024-02-26-170000_add_pkce_to_sso_nonce/up.sql create mode 100644 migrations/mysql/2024-03-06-170000_add_sso_users/down.sql create mode 100644 migrations/mysql/2024-03-06-170000_add_sso_users/up.sql create mode 100644 migrations/mysql/2024-03-13-170000_sso_users_cascade/down.sql create mode 100644 migrations/mysql/2024-03-13-170000_sso_users_cascade/up.sql delete mode 100644 migrations/postgresql/2023-02-01-133000_add_sso/up.sql rename migrations/postgresql/{2023-02-01-133000_add_sso => 2023-09-10-133000_add_sso}/down.sql (100%) create mode 100644 migrations/postgresql/2023-09-10-133000_add_sso/up.sql create mode 100644 migrations/postgresql/2023-09-14-133000_add_users_organizations_invited_by_email/down.sql create mode 100644 migrations/postgresql/2023-09-14-133000_add_users_organizations_invited_by_email/up.sql create mode 100644 migrations/postgresql/2024-02-14-170000_add_state_to_sso_nonce/down.sql create mode 100644 migrations/postgresql/2024-02-14-170000_add_state_to_sso_nonce/up.sql create mode 100644 migrations/postgresql/2024-02-26-170000_add_pkce_to_sso_nonce/down.sql create mode 100644 migrations/postgresql/2024-02-26-170000_add_pkce_to_sso_nonce/up.sql create mode 100644 migrations/postgresql/2024-03-06-170000_add_sso_users/down.sql create mode 100644 migrations/postgresql/2024-03-06-170000_add_sso_users/up.sql create mode 100644 migrations/postgresql/2024-03-13-170000_sso_users_cascade/down.sql create mode 100644 migrations/postgresql/2024-03-13-170000_sso_users_cascade/up.sql delete mode 100644 migrations/sqlite/2023-02-01-133000_add_sso/up.sql rename migrations/sqlite/{2023-02-01-133000_add_sso => 2023-09-10-133000_add_sso}/down.sql (100%) create mode 100644 migrations/sqlite/2023-09-10-133000_add_sso/up.sql create mode 100644 migrations/sqlite/2023-09-14-133000_add_users_organizations_invited_by_email/down.sql create mode 100644 migrations/sqlite/2023-09-14-133000_add_users_organizations_invited_by_email/up.sql create mode 100644 migrations/sqlite/2024-02-14-170000_add_state_to_sso_nonce/down.sql create mode 100644 migrations/sqlite/2024-02-14-170000_add_state_to_sso_nonce/up.sql create mode 100644 migrations/sqlite/2024-02-26-170000_add_pkce_to_sso_nonce/down.sql create mode 100644 migrations/sqlite/2024-02-26-170000_add_pkce_to_sso_nonce/up.sql create mode 100644 migrations/sqlite/2024-03-06-170000_add_sso_users/down.sql create mode 100644 migrations/sqlite/2024-03-06-170000_add_sso_users/up.sql create mode 100644 migrations/sqlite/2024-03-13_170000_sso_userscascade/down.sql create mode 100644 migrations/sqlite/2024-03-13_170000_sso_userscascade/up.sql create mode 100644 src/sso.rs create mode 100644 src/static/templates/email/sso_change_email.hbs create mode 100644 src/static/templates/email/sso_change_email.html.hbs diff --git a/.env.template b/.env.template index 37ee9a4d02..770d799741 100644 --- a/.env.template +++ b/.env.template @@ -161,6 +161,10 @@ ## Cron schedule of the job that cleans expired Duo contexts from the database. Does nothing if Duo MFA is disabled or set to use the legacy iframe prompt. ## Defaults to every minute. Set blank to disable this job. # DUO_CONTEXT_PURGE_SCHEDULE="30 * * * * *" +# +## Cron schedule of the job that cleans sso nonce from incomplete flow +## Defaults to daily (20 minutes after midnight). Set blank to disable this job. +# PURGE_INCOMPLETE_SSO_NONCE="0 20 0 * * *" ######################## ### General settings ### @@ -443,11 +447,31 @@ # 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 ## Base URL of the OIDC server (auto-discovery is used) +## - Should not include the `/.well-known/openid-configuration` part and no trailing `/` +## - ${SSO_AUTHORITY}/.well-known/openid-configuration should return a json document: https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationResponse # SSO_AUTHORITY=https://auth.example.com +## Authorization request scopes. Optional SSO scopes, override if email and profile are not enough (`openid` is implicit). +#SSO_SCOPES="email profile" +## Additionnal authorization url parameters (ex: to obtain a `refresh_token` with Google Auth). +# SSO_AUTHORIZE_EXTRA_PARAMS="access_type=offline&prompt=consent" +## Activate PKCE for the Auth Code flow. Recommended but disabled for now waiting for feedback on support. +# SSO_PKCE=false +## Regex to add additionnal trusted audience to Id Token (by default only the client_id is trusted). +# SSO_AUDIENCE_TRUSTED='^$' ## Set your Client ID and Client Key # SSO_CLIENT_ID=11111 # SSO_CLIENT_SECRET=AAAAAAAAAAAAAAAAAAAAAAAA +## Optional Master password policy (minComplexity=[0-4]) +# SSO_MASTER_PASSWORD_POLICY='{"enforceOnLogin":false,"minComplexity":3,"minLength":12,"requireLower":false,"requireNumbers":false,"requireSpecial":false,"requireUpper":false}' +## Use sso only for authentication not the session lifecycle +# SSO_AUTH_ONLY_NOT_SESSION=false +## Client cache for discovery endpoint. Duration in seconds (0 to disable). +# SSO_CLIENT_CACHE_EXPIRATION=0 +## Log all the tokens, LOG_LEVEL=debug is required +# SSO_DEBUG_TOKENS=false ######################## ### MFA/2FA settings ### diff --git a/Cargo.lock b/Cargo.lock index 9edd20bf36..d46bdd9713 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -55,9 +55,9 @@ dependencies = [ [[package]] name = "allocator-api2" -version = "0.2.19" +version = "0.2.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "611cc2ae7d2e242c457e4be7f97036b8ad9ca152b499f53faf99b1ed8fc2553f" +checksum = "45862d1c77f2228b9e10bc609d5bc203d86ebc9b87ad8d5d5167a6c9abf739d9" [[package]] name = "android-tzdata" @@ -326,6 +326,12 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.13.1" @@ -439,6 +445,12 @@ version = "3.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" +[[package]] +name = "bytecount" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ce89b21cab1437276d2650d57e971f9d548a2d9037cc231abdc0562b97498ce" + [[package]] name = "bytemuck" version = "1.19.0" @@ -493,11 +505,42 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0" +[[package]] +name = "camino" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b96ec4966b5813e2c0507c1f86115c8c5abaadc3980879c3424042a02fd1ad3" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo-platform" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24b1f0365a6c6bb4020cd05806fd0d33c44d38046b8bd7f0e40814b9763cabfc" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4acbb09d9ee8e23699b9634375c72795d095bf268439da88562cf9b501f181fa" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", +] + [[package]] name = "cc" -version = "1.1.37" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40545c26d092346d8a8dab71ee48e7685a7a9cba76e634790c215b41a4a7b4cf" +checksum = "1aeb932158bd710538c73702db6945cb68a8fb08c519e6e12706b94263b36db8" dependencies = [ "shlex", ] @@ -516,8 +559,10 @@ checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" dependencies = [ "android-tzdata", "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-targets 0.52.6", ] @@ -567,6 +612,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "cookie" version = "0.18.1" @@ -614,9 +665,9 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "cpufeatures" -version = "0.2.14" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "608697df725056feaccfa42cffdaeeec3fccc4ffc38358ecd19b243e716a78e0" +checksum = "0ca741a962e1b0bff6d724a1a0958b686406e853bb14061f218562e1896f95e6" dependencies = [ "libc", ] @@ -641,12 +692,33 @@ dependencies = [ "once_cell", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33480d6946193aa8033910124896ca395333cae7e2d1113d1fef6c3272217df2" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" +[[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", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -657,6 +729,33 @@ dependencies = [ "typenum", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "darling" version = "0.20.10" @@ -692,6 +791,19 @@ dependencies = [ "syn", ] +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "dashmap" version = "6.1.0" @@ -718,6 +830,17 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c297a1c74b71ae29df00c3e22dd9534821d60eb9af5a0192823fa2acea70c2a" +[[package]] +name = "der" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f55bf8e7b65898637379c1b74eb1551107c8294ed26d855ceb9fd1a09cfc9bc0" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.3.11" @@ -725,6 +848,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" dependencies = [ "powerfmt", + "serde", ] [[package]] @@ -834,6 +958,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", "subtle", ] @@ -878,12 +1003,77 @@ dependencies = [ "syn", ] +[[package]] +name = "dyn-clone" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d6ef0072f8a535281e4876be788938b528e9a1d43900b82c2569af7da799125" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a3daa8e81a3963a60642bcc1f90a670680bd4a77535faa384e9d1c79d620871" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "email-encoding" version = "0.3.0" @@ -994,6 +1184,22 @@ dependencies = [ "syslog", ] +[[package]] +name = "ff" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ded41244b729663b1e574f1b4fb731469f69f79c17667b5d776b16cda0479449" +dependencies = [ + "rand_core", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "figment" version = "0.10.19" @@ -1177,6 +1383,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1223,7 +1430,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0746aa765db78b521451ef74221663b57ba595bf83f75d0ce23cc09447c8139f" dependencies = [ "cfg-if", - "dashmap", + "dashmap 6.1.0", "futures-sink", "futures-timer", "futures-util", @@ -1244,12 +1451,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d9e3df7f0222ce5184154973d247c591d9aadc28ce7a73c6cd31100c9facff6" dependencies = [ "codemap", - "indexmap", + "indexmap 2.6.0", "lasso", "once_cell", "phf", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + [[package]] name = "h2" version = "0.3.26" @@ -1262,7 +1480,7 @@ dependencies = [ "futures-sink", "futures-util", "http 0.2.12", - "indexmap", + "indexmap 2.6.0", "slab", "tokio", "tokio-util", @@ -1281,7 +1499,7 @@ dependencies = [ "futures-core", "futures-sink", "http 1.1.0", - "indexmap", + "indexmap 2.6.0", "slab", "tokio", "tokio-util", @@ -1310,6 +1528,12 @@ dependencies = [ "walkdir", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.14.5" @@ -1344,6 +1568,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hickory-proto" version = "0.24.1" @@ -1389,6 +1619,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + [[package]] name = "hmac" version = "0.12.1" @@ -1550,6 +1789,20 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.31", + "rustls 0.21.12", + "tokio", + "tokio-rustls 0.24.1", +] + [[package]] name = "hyper-rustls" version = "0.27.3" @@ -1803,6 +2056,17 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.6.0" @@ -1849,6 +2113,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.11" @@ -1919,6 +2192,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "lettre" @@ -2108,6 +2384,21 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mini-moka" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c325dfab65f261f386debee8b0969da215b3fa0037e74c8a1234db7ba986d803" +dependencies = [ + "crossbeam-channel", + "crossbeam-utils", + "dashmap 5.5.3", + "skeptic", + "smallvec", + "tagptr", + "triomphe", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -2223,6 +2514,23 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" +dependencies = [ + "byteorder", + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -2249,6 +2557,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-modular" version = "0.6.1" @@ -2271,6 +2590,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -2292,6 +2612,26 @@ dependencies = [ "libc", ] +[[package]] +name = "oauth2" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c38841cdd844847e3e7c8d29cef9dcfed8877f8f56f9071f77843ecf3baf937f" +dependencies = [ + "base64 0.13.1", + "chrono", + "getrandom", + "http 0.2.12", + "rand", + "reqwest 0.11.27", + "serde", + "serde_json", + "serde_path_to_error", + "sha2", + "thiserror", + "url", +] + [[package]] name = "object" version = "0.36.5" @@ -2307,6 +2647,38 @@ version = "1.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" +[[package]] +name = "openidconnect" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f47e80a9cfae4462dd29c41e987edd228971d6565553fbc14b8a11e666d91590" +dependencies = [ + "base64 0.13.1", + "chrono", + "dyn-clone", + "ed25519-dalek", + "hmac", + "http 0.2.12", + "itertools", + "log", + "oauth2", + "p256", + "p384", + "rand", + "rsa", + "serde", + "serde-value", + "serde_derive", + "serde_json", + "serde_path_to_error", + "serde_plain", + "serde_with", + "sha2", + "subtle", + "thiserror", + "url", +] + [[package]] name = "openssl" version = "0.10.68" @@ -2361,12 +2733,45 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + [[package]] name = "overload" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70786f51bcc69f6a4c0360e063a4cac5419ef7c5cd5b3c99ad70f3be5ba79209" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + [[package]] name = "parking" version = "2.2.1" @@ -2455,6 +2860,15 @@ dependencies = [ "serde", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.1" @@ -2587,6 +3001,27 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.31" @@ -2638,6 +3073,15 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" version = "1.0.89" @@ -2685,6 +3129,17 @@ dependencies = [ "psl-types", ] +[[package]] +name = "pulldown-cmark" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57206b407293d2bcd3af849ce869d52068623f19e1b5ff8e8778e3309439682b" +dependencies = [ + "bitflags 2.6.0", + "memchr", + "unicase", +] + [[package]] name = "quanta" version = "0.12.3" @@ -2808,7 +3263,7 @@ checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.8", + "regex-automata 0.4.9", "regex-syntax 0.8.5", ] @@ -2823,9 +3278,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.8" +version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ "aho-corasick", "memchr", @@ -2870,6 +3325,7 @@ dependencies = [ "http 0.2.12", "http-body 0.4.6", "hyper 0.14.31", + "hyper-rustls 0.24.2", "hyper-tls 0.5.0", "ipnet", "js-sys", @@ -2879,6 +3335,7 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", + "rustls 0.21.12", "rustls-pemfile 1.0.4", "serde", "serde_json", @@ -2887,11 +3344,13 @@ dependencies = [ "system-configuration 0.5.1", "tokio", "tokio-native-tls", + "tokio-rustls 0.24.1", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "webpki-roots", "winreg", ] @@ -2914,7 +3373,7 @@ dependencies = [ "http-body 1.0.1", "http-body-util", "hyper 1.5.0", - "hyper-rustls", + "hyper-rustls 0.27.3", "hyper-tls 0.6.0", "hyper-util", "ipnet", @@ -2954,6 +3413,16 @@ dependencies = [ "quick-error", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.8" @@ -3004,7 +3473,7 @@ dependencies = [ "either", "figment", "futures", - "indexmap", + "indexmap 2.6.0", "log", "memchr", "multer", @@ -3036,7 +3505,7 @@ checksum = "575d32d7ec1a9770108c879fc7c47815a80073f96ca07ff9525a94fcede1dd46" dependencies = [ "devise", "glob", - "indexmap", + "indexmap 2.6.0", "proc-macro2", "quote", "rocket_http", @@ -3056,7 +3525,7 @@ dependencies = [ "futures", "http 0.2.12", "hyper 0.14.31", - "indexmap", + "indexmap 2.6.0", "log", "memchr", "pear", @@ -3096,6 +3565,26 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "rsa" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e5124fcb30e76a7e79bfee683a2746db83784b86289f6251b54b7950a0dfc" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rtoolbox" version = "0.0.2" @@ -3112,6 +3601,15 @@ version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.40" @@ -3256,6 +3754,20 @@ dependencies = [ "untrusted", ] +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "2.11.1" @@ -3284,16 +3796,29 @@ name = "semver" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" +dependencies = [ + "serde", +] [[package]] name = "serde" -version = "1.0.214" +version = "1.0.215" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f55c3193aca71c12ad7890f1785d2b73e1b9f63a0bbc353c08ef26fe03fc56b5" +checksum = "6513c1ad0b11a9376da888e3e0baa0077f1aed55c17f50e7b2397136129fb88f" dependencies = [ "serde_derive", ] +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + [[package]] name = "serde_cbor" version = "0.11.2" @@ -3306,9 +3831,9 @@ dependencies = [ [[package]] name = "serde_derive" -version = "1.0.214" +version = "1.0.215" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de523f781f095e28fa605cdce0f8307e451cc0fd14e2eb4cd2e98a355b147766" +checksum = "ad1e866f866923f252f05c889987993144fb74e722403468a4ebd70c3cd756c0" dependencies = [ "proc-macro2", "quote", @@ -3327,6 +3852,25 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af99884400da37c88f5e9146b7f1fd0fbcae8f6eec4e9da38b67d05486f814a6" +dependencies = [ + "itoa", + "serde", +] + +[[package]] +name = "serde_plain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" +dependencies = [ + "serde", +] + [[package]] name = "serde_spanned" version = "0.6.8" @@ -3348,6 +3892,36 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28bdad6db2b8340e449f7108f020b3b092e8583a9e3fb82713e1d4e71fe817" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.6.0", + "serde", + "serde_derive", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d846214a9854ef724f3da161b426242d8de7c1fc7de2f89bb1efcb154dca79d" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sha1" version = "0.10.6" @@ -3404,6 +3978,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + [[package]] name = "simple_asn1" version = "0.6.2" @@ -3422,6 +4006,21 @@ version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" +[[package]] +name = "skeptic" +version = "0.13.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16d23b015676c90a0f01c197bfdc786c20342c73a0afdda9025adb0bc42940a8" +dependencies = [ + "bytecount", + "cargo_metadata", + "error-chain", + "glob", + "pulldown-cmark", + "tempfile", + "walkdir", +] + [[package]] name = "slab" version = "0.4.9" @@ -3462,6 +4061,16 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable-pattern" version = "0.1.0" @@ -3603,6 +4212,12 @@ dependencies = [ "libc", ] +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "tempfile" version = "3.14.0" @@ -3848,7 +4463,7 @@ version = "0.22.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" dependencies = [ - "indexmap", + "indexmap 2.6.0", "serde", "serde_spanned", "toml_datetime", @@ -3935,6 +4550,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "triomphe" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8f7726da4807b58ea5c96fdc122f80702030edc33b35aff9190a51148ccc85" + [[package]] name = "try-lock" version = "0.2.5" @@ -3991,6 +4612,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "unicase" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e51b68083f157f853b6379db119d1c1be0e6e4dec98101079dec41f6f5cf6df" + [[package]] name = "unicode-bidi" version = "0.3.17" @@ -4087,7 +4714,7 @@ dependencies = [ "chrono-tz", "cookie", "cookie_store", - "dashmap", + "dashmap 6.1.0", "data-encoding", "data-url", "diesel", @@ -4108,9 +4735,11 @@ dependencies = [ "libsqlite3-sys", "log", "mimalloc", + "mini-moka", "num-derive", "num-traits", "once_cell", + "openidconnect", "openssl", "paste", "percent-encoding", @@ -4294,6 +4923,12 @@ dependencies = [ "url", ] +[[package]] +name = "webpki-roots" +version = "0.25.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" + [[package]] name = "which" version = "7.0.0" diff --git a/Cargo.toml b/Cargo.toml index 69ba72c1ca..44e6dbbad6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -150,7 +150,8 @@ paste = "1.0.15" governor = "0.7.0" # OIDC for SSO -openidconnect = "3.4.0" +openidconnect = "3.5.0" +mini-moka = "0.10.2" # Check client versions for specific features. semver = "1.0.23" diff --git a/SSO.md b/SSO.md new file mode 100644 index 0000000000..0ccdc3498d --- /dev/null +++ b/SSO.md @@ -0,0 +1,286 @@ +# 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_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. Recommended but disabled for now waiting for feedback on support (default `false`). + - `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 + - `SSO_AUTH_ONLY_NOT_SESSION`: Enable to use SSO only for authentication not session lifecycle + - `SSO_CLIENT_CACHE_EXPIRATION`: Cache calls to the discovery endpoint, duration in seconds, `0` to disable (default `0`); + - `SSO_DEBUG_TOKENS`: Log all tokens (default `false`, `LOG_LEVEL=debug` is required) + +The callback url is : `https://your.domain/identity/connect/oidc-signin` + +## Account and Email handling + +When logging in with SSO an identifier (`{iss}/{sub}` claims from the IdToken) is saved in a separate table (`sso_users`). +This is used to link to the SSO provider identifier without changing the default Vaultwarden user `uuid`. This is needed because: + + - Storing the SSO identifier is important to prevent account takeover due to email change. + - We can't use the identifier as the User uuid since it's way longer (Max 255 chars for the `sub` part, cf [spec](https://openid.net/specs/openid-connect-core-1_0.html#IDToken)). + - We want to be able to associate existing account based on `email` but only when the user logs in for the first time (controlled by `SSO_SIGNUPS_MATCH_EMAIL`). + - We need to be able to associate with existing stub account, such as the one created when inviting a user to an org (association is possible only if the user does not have a private key). + +Additionally: + + - Signup to Vaultwarden will be blocked if the Provider reports the email as `unverified`. + - Changing the email needs to be done by the user since it requires updating the `key`. + On login if the email returned by the provider is not the one saved in Vaultwarden an email will be sent to the user to ask him to update it. + - If set `SIGNUPS_DOMAINS_WHITELIST` is applied on SSO signup and when attempting to change the email. + +This means that if you ever need to change the provider url or the provider itself; you'll have to first delete the association +then ensure that `SSO_SIGNUPS_MATCH_EMAIL` is activated to allow a new association. + +To delete the association (this has no impact on the `Vaultwarden` user): + +```sql +TRUNCATE TABLE sso_users; +``` + +## Client Cache + +By default the client cache is disabled since it can cause issues with the signing keys. +\ +This means that the discovery endpoint will be called again each time we need to interact with the provider (generating authorize_url, exchange the authorize code, refresh tokens). +This is suboptimal so the `SSO_CLIENT_CACHE_EXPIRATION` allows you to configure an expiration that should work for your provider. + +As a protection against a misconfigured expiration if the validation of the `IdToken` fails then the client cache is invalidated (but you'll periodically have an unlucky user ^^). + +### Google example (Rolling keys) + +If we take Google as an example checking the discovery [endpoint](https://accounts.google.com/.well-known/openid-configuration) response headers we can see that the `max-age` of the cache control is set to `3600` seconds. And the [jwk_uri](https://www.googleapis.com/oauth2/v3/certs) response headers usually contain a `max-age` with an even bigger value. +/ +Combined with user [feedback](https://github.com/ramosbugs/openidconnect-rs/issues/152) we can conclude that Google will roll the signing keys each week. + +Setting the cache expiration too high has diminishing return but using something like `600` (10 min) should provide plenty benefits. + +### Rolling keys manually + +If you want to roll the used key, first add a new one but do not immediately start signing with it. +Wait for the delay you configured in `SSO_CLIENT_CACHE_EXPIRATION` then you can start signing with it. + +As mentioned in the Google example setting too high of a value has diminishing return even if you do not plan to roll the keys. + +## Keycloak + +Default access token lifetime might be only `5min`, set a longer value otherwise it will collide with `VaultWarden` front-end expiration detection which is also set at `5min`. +\ +At the realm level + +- `Realm settings / Tokens / Access Token Lifespan` to at least `10min` (`accessTokenLifespan` setting when using `kcadm.sh`). +- `Realm settings / Sessions / SSO Session Idle/Max` for the Refresh token lifetime + +Or for a specific client in `Clients / Client details / Advanced / Advanced settings` you can find `Access Token Lifespan` and `Client Session Idle/Max`. + +Server configuration, nothing specific just set: + +- `SSO_AUTHORITY=https://${domain}/realms/${realm_name}` +- `SSO_CLIENT_ID` +- `SSO_CLIENT_SECRET` +- `SSO_PKCE=true` + +## Auth0 + +Not working due to the following issue https://github.com/ramosbugs/openidconnect-rs/issues/23 (they appear not to follow the spec). +A feature flag is available to bypass the issue but since it's a compile time feature you will have to patch `Vaultwarden` with something like: + +```patch +diff --git a/Cargo.toml b/Cargo.toml +index 0524a7be..9999e852 100644 +--- a/Cargo.toml ++++ b/Cargo.toml +@@ -150,7 +150,7 @@ paste = "1.0.15" + governor = "0.6.3" + + # OIDC for SSO +-openidconnect = "3.5.0" ++openidconnect = { version = "3.5.0", features = ["accept-rfc3339-timestamps"] } + mini-moka = "0.10.2" +``` + +There is no plan at the moment to either always activate the feature nor make a specific distribution for Auth0. + +## Authelia + +To obtain a `refresh_token` to be able to extend session you'll need to add the `offline_access` scope. + +Config will look like: + +- `SSO_SCOPES="email profile offline_access"` + + +## Authentik + +Default access token lifetime might be only `5min`, set a longer value otherwise it will collide with `VaultWarden` front-end expiration detection which is also set at `5min`. +\ +To change the tokens expiration go to `Applications / Providers / Edit / Advanced protocol settings`. + +Starting with `2024.2` version you will need to add the `offline_access` scope and ensure it's selected in `Applications / Providers / Edit / Advanced protocol settings / Scopes` ([Doc](https://docs.goauthentik.io/docs/providers/oauth2/#authorization_code)). + +Server configuration should look like: + +- `SSO_AUTHORITY=https://${domain}/application/o/${application_name}/` : trailing `/` is important +- `SSO_SCOPES="email profile offline_access"` +- `SSO_CLIENT_ID` +- `SSO_CLIENT_SECRET` +- `SSO_PKCE=true` + +## 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` +- `SSO_PKCE=true` + +## 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` +- `SSO_PKCE=true` + +## 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_PKCE=true` +- `SSO_CLIENT_ID` +- `SSO_CLIENT_SECRET` + +## Kanidm + +Kanidm recommend always running with PKCE: + +Config will look like: + +- `SSO_PKCE=true` + +Otherwise you can disable the PKCE requirement with: `kanidm system oauth2 warning-insecure-client-disable-pkce CLIENT_NAME --name admin`. + +## Microsoft Entra ID + +1. Create an "App registration" in [Entra ID](https://entra.microsoft.com/) following [Identity | Applications | App registrations](https://entra.microsoft.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade/quickStartType//sourceType/Microsoft_AAD_IAM). +2. From the "Overview" of your "App registration", you'll need the "Directory (tenant) ID" for the `SSO_AUTHORITY` variable and the "Application (client) ID" as the `SSO_CLIENT_ID` value. +3. In "Certificates & Secrets" create an "App secret" , you'll need the "Secret Value" for the `SSO_CLIENT_SECRET` variable. +4. In "Authentication" add as "Web Redirect URI". +5. In "API Permissions" make sure you have `profile`, `email` and `offline_access` listed under "API / Permission name" (`offline_access` is required, otherwise no refresh_token is returned, see ). + +Only the v2 endpoint is compliant with the OpenID spec, see and . + +Your configuration should look like this: + +* `SSO_AUTHORITY=https://login.microsoftonline.com/${Directory (tenant) ID}/v2.0` +* `SSO_SCOPES="email profile offline_access"` +* `SSO_CLIENT_ID=${Application (client) ID}` +* `SSO_CLIENT_SECRET=${Secret Value}` + +## Zitadel + +To obtain a `refresh_token` to be able to extend session you'll need to add the `offline_access` scope. + +Additionally Zitadel include the `Project id` and the `Client Id` in the audience of the Id Token. +For the validation to work you will need to add the `Resource Id` as a trusted audience (`Client Id` is trusted by default). +You can control the trusted audience with the config `SSO_AUDIENCE_TRUSTED` + +It appears it's not possible to use PKCE with confidential client so it needs to be disabled. + +Config will look like: + +- `SSO_AUTHORITY=https://${provider_host}` +- `SSO_SCOPES="email profile offline_access"` +- `SSO_CLIENT_ID` +- `SSO_CLIENT_SECRET` +- `SSO_AUDIENCE_TRUSTED='^${Project Id}$'` +- `SSO_PKCE=false` + +## Session lifetime + +Session lifetime is dependant on refresh token and access token returned after calling your SSO token endpoint (grant type `authorization_code`). +If no refresh token is returned then the session will be limited to the access token lifetime. + +Tokens are not persisted in VaultWarden but wrapped in JWT tokens and returned to the application (The `refresh_token` and `access_token` values returned by VW `identity/connect/token` endpoint). +Note that VaultWarden will always return a `refresh_token` for compatibility reasons with the web front and it presence does not indicate that a refresh token was returned by your SSO (But you can decode its value with and then check if the `token` field contain anything). + +With a refresh token present, activity in the application will trigger a refresh of the access token when it's close to expiration ([5min](https://github.com/bitwarden/clients/blob/0bcb45ed5caa990abaff735553a5046e85250f24/libs/common/src/auth/services/token.service.ts#L126) in web client). + +Additionally for certain action a token check is performed, if we have a refresh token we will perform a refresh otherwise we'll call the user information endpoint to check the access token validity. + +### Disabling SSO session handling + +If you are unable to obtain a `refresh_token` or for any other reason you can disable SSO session handling and revert to the default handling. +You'll need to enable `SSO_AUTH_ONLY_NOT_SESSION=true` then access token will be valid for 2h and refresh token will allow for an idle time of 7 days (which can be indefinitely extended). + +### Debug information + +Running with `LOG_LEVEL=debug` you'll be able to see information on token expiration. + +## Desktop Client + +There is some issue to handle redirection from your browser (used for sso login) to the application. + +### Chrome + +Probably not much hope, an [issue](https://github.com/bitwarden/clients/issues/2606) is open on the subject and it appears that both Linux and Windows are not working. + +## Firefox + +On Windows you'll be presented with a prompt the first time you log to confirm which application should be launched (But there is a bug at the moment you might end-up with an empty vault after login atm). + + +On Linux it's a bit more tricky. +First you'll need to add some config in `about:config` : + +```conf +network.protocol-handler.expose.bitwarden=false +network.protocol-handler.external.bitwarden=true +``` + +If you have any doubt you can check `mailto` to see how it's configured. + +The redirection will still not work since it appears that the association to an application can only be done on a link/click. You can trigger it with a dummy page such as: + +```html +data:text/html,Click me to register Bitwarden +``` + +From now on the redirection should now work. +If you need to change the application launched you can now find it in `Settings` by using the search function and entering `application`. diff --git a/docker/start.sh b/docker/start.sh index 4fac45142f..1f50883dba 100755 --- a/docker/start.sh +++ b/docker/start.sh @@ -26,4 +26,11 @@ elif [ -d /etc/bitwarden_rs.d ]; then done fi +# Toggle the SSO Link +if [ "$SSO_ENABLED" = "true" ]; then + sed -i 's#a\[routerlink="/sso"\]#a\[routerlink="/sso-sed"\]#' /web-vault/app/main.*.css +else + sed -i 's#a\[routerlink="/sso-sed"\]#a\[routerlink="/sso"\]#' /web-vault/app/main.*.css +fi + exec /vaultwarden "${@}" diff --git a/migrations/mysql/2023-02-01-133000_add_sso/up.sql b/migrations/mysql/2023-02-01-133000_add_sso/up.sql deleted file mode 100644 index c10ab5cfe6..0000000000 --- a/migrations/mysql/2023-02-01-133000_add_sso/up.sql +++ /dev/null @@ -1,3 +0,0 @@ -CREATE TABLE sso_nonce ( - nonce CHAR(36) NOT NULL PRIMARY KEY -); diff --git a/migrations/mysql/2023-02-01-133000_add_sso/down.sql b/migrations/mysql/2023-09-10-133000_add_sso/down.sql similarity index 100% rename from migrations/mysql/2023-02-01-133000_add_sso/down.sql rename to migrations/mysql/2023-09-10-133000_add_sso/down.sql diff --git a/migrations/mysql/2023-09-10-133000_add_sso/up.sql b/migrations/mysql/2023-09-10-133000_add_sso/up.sql new file mode 100644 index 0000000000..518664df7d --- /dev/null +++ b/migrations/mysql/2023-09-10-133000_add_sso/up.sql @@ -0,0 +1,4 @@ +CREATE TABLE sso_nonce ( + nonce CHAR(36) NOT NULL PRIMARY KEY, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/migrations/mysql/2023-09-14-133000_add_users_organizations_invited_by_email/down.sql b/migrations/mysql/2023-09-14-133000_add_users_organizations_invited_by_email/down.sql new file mode 100644 index 0000000000..3a708927ed --- /dev/null +++ b/migrations/mysql/2023-09-14-133000_add_users_organizations_invited_by_email/down.sql @@ -0,0 +1 @@ +ALTER TABLE users_organizations DROP COLUMN invited_by_email; diff --git a/migrations/mysql/2023-09-14-133000_add_users_organizations_invited_by_email/up.sql b/migrations/mysql/2023-09-14-133000_add_users_organizations_invited_by_email/up.sql new file mode 100644 index 0000000000..c94e1131eb --- /dev/null +++ b/migrations/mysql/2023-09-14-133000_add_users_organizations_invited_by_email/up.sql @@ -0,0 +1 @@ +ALTER TABLE users_organizations ADD COLUMN invited_by_email TEXT DEFAULT NULL; diff --git a/migrations/mysql/2024-02-14-170000_add_state_to_sso_nonce/down.sql b/migrations/mysql/2024-02-14-170000_add_state_to_sso_nonce/down.sql new file mode 100644 index 0000000000..bce3122209 --- /dev/null +++ b/migrations/mysql/2024-02-14-170000_add_state_to_sso_nonce/down.sql @@ -0,0 +1,6 @@ +DROP TABLE IF EXISTS sso_nonce; + +CREATE TABLE sso_nonce ( + nonce CHAR(36) NOT NULL PRIMARY KEY, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/migrations/mysql/2024-02-14-170000_add_state_to_sso_nonce/up.sql b/migrations/mysql/2024-02-14-170000_add_state_to_sso_nonce/up.sql new file mode 100644 index 0000000000..f73aeea914 --- /dev/null +++ b/migrations/mysql/2024-02-14-170000_add_state_to_sso_nonce/up.sql @@ -0,0 +1,8 @@ +DROP TABLE IF EXISTS sso_nonce; + +CREATE TABLE sso_nonce ( + state VARCHAR(512) NOT NULL PRIMARY KEY, + nonce TEXT NOT NULL, + redirect_uri TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT now() +); diff --git a/migrations/mysql/2024-02-26-170000_add_pkce_to_sso_nonce/down.sql b/migrations/mysql/2024-02-26-170000_add_pkce_to_sso_nonce/down.sql new file mode 100644 index 0000000000..c033f7cbce --- /dev/null +++ b/migrations/mysql/2024-02-26-170000_add_pkce_to_sso_nonce/down.sql @@ -0,0 +1,8 @@ +DROP TABLE IF EXISTS sso_nonce; + +CREATE TABLE sso_nonce ( + state VARCHAR(512) NOT NULL PRIMARY KEY, + nonce TEXT NOT NULL, + redirect_uri TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT now() +); diff --git a/migrations/mysql/2024-02-26-170000_add_pkce_to_sso_nonce/up.sql b/migrations/mysql/2024-02-26-170000_add_pkce_to_sso_nonce/up.sql new file mode 100644 index 0000000000..42fb0efa5b --- /dev/null +++ b/migrations/mysql/2024-02-26-170000_add_pkce_to_sso_nonce/up.sql @@ -0,0 +1,9 @@ +DROP TABLE IF EXISTS sso_nonce; + +CREATE TABLE sso_nonce ( + state VARCHAR(512) NOT NULL PRIMARY KEY, + nonce TEXT NOT NULL, + verifier TEXT, + redirect_uri TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT now() +); diff --git a/migrations/mysql/2024-03-06-170000_add_sso_users/down.sql b/migrations/mysql/2024-03-06-170000_add_sso_users/down.sql new file mode 100644 index 0000000000..f2f92f6822 --- /dev/null +++ b/migrations/mysql/2024-03-06-170000_add_sso_users/down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS sso_users; diff --git a/migrations/mysql/2024-03-06-170000_add_sso_users/up.sql b/migrations/mysql/2024-03-06-170000_add_sso_users/up.sql new file mode 100644 index 0000000000..7809d43e93 --- /dev/null +++ b/migrations/mysql/2024-03-06-170000_add_sso_users/up.sql @@ -0,0 +1,7 @@ +CREATE TABLE sso_users ( + user_uuid CHAR(36) NOT NULL PRIMARY KEY, + identifier VARCHAR(768) NOT NULL UNIQUE, + created_at TIMESTAMP NOT NULL DEFAULT now(), + + FOREIGN KEY(user_uuid) REFERENCES users(uuid) +); diff --git a/migrations/mysql/2024-03-13-170000_sso_users_cascade/down.sql b/migrations/mysql/2024-03-13-170000_sso_users_cascade/down.sql new file mode 100644 index 0000000000..e69de29bb2 diff --git a/migrations/mysql/2024-03-13-170000_sso_users_cascade/up.sql b/migrations/mysql/2024-03-13-170000_sso_users_cascade/up.sql new file mode 100644 index 0000000000..4e06fe58e1 --- /dev/null +++ b/migrations/mysql/2024-03-13-170000_sso_users_cascade/up.sql @@ -0,0 +1,2 @@ +ALTER TABLE sso_users DROP FOREIGN KEY `sso_users_ibfk_1`; +ALTER TABLE sso_users ADD FOREIGN KEY(user_uuid) REFERENCES users(uuid) ON UPDATE CASCADE ON DELETE CASCADE; diff --git a/migrations/postgresql/2023-02-01-133000_add_sso/up.sql b/migrations/postgresql/2023-02-01-133000_add_sso/up.sql deleted file mode 100644 index 57f976c152..0000000000 --- a/migrations/postgresql/2023-02-01-133000_add_sso/up.sql +++ /dev/null @@ -1,3 +0,0 @@ -CREATE TABLE sso_nonce ( - nonce CHAR(36) NOT NULL PRIMARY KEY -); \ No newline at end of file diff --git a/migrations/postgresql/2023-02-01-133000_add_sso/down.sql b/migrations/postgresql/2023-09-10-133000_add_sso/down.sql similarity index 100% rename from migrations/postgresql/2023-02-01-133000_add_sso/down.sql rename to migrations/postgresql/2023-09-10-133000_add_sso/down.sql diff --git a/migrations/postgresql/2023-09-10-133000_add_sso/up.sql b/migrations/postgresql/2023-09-10-133000_add_sso/up.sql new file mode 100644 index 0000000000..1321e24653 --- /dev/null +++ b/migrations/postgresql/2023-09-10-133000_add_sso/up.sql @@ -0,0 +1,4 @@ +CREATE TABLE sso_nonce ( + nonce CHAR(36) NOT NULL PRIMARY KEY, + created_at TIMESTAMP NOT NULL DEFAULT now() +); diff --git a/migrations/postgresql/2023-09-14-133000_add_users_organizations_invited_by_email/down.sql b/migrations/postgresql/2023-09-14-133000_add_users_organizations_invited_by_email/down.sql new file mode 100644 index 0000000000..3a708927ed --- /dev/null +++ b/migrations/postgresql/2023-09-14-133000_add_users_organizations_invited_by_email/down.sql @@ -0,0 +1 @@ +ALTER TABLE users_organizations DROP COLUMN invited_by_email; diff --git a/migrations/postgresql/2023-09-14-133000_add_users_organizations_invited_by_email/up.sql b/migrations/postgresql/2023-09-14-133000_add_users_organizations_invited_by_email/up.sql new file mode 100644 index 0000000000..c94e1131eb --- /dev/null +++ b/migrations/postgresql/2023-09-14-133000_add_users_organizations_invited_by_email/up.sql @@ -0,0 +1 @@ +ALTER TABLE users_organizations ADD COLUMN invited_by_email TEXT DEFAULT NULL; diff --git a/migrations/postgresql/2024-02-14-170000_add_state_to_sso_nonce/down.sql b/migrations/postgresql/2024-02-14-170000_add_state_to_sso_nonce/down.sql new file mode 100644 index 0000000000..7cf4d9d6be --- /dev/null +++ b/migrations/postgresql/2024-02-14-170000_add_state_to_sso_nonce/down.sql @@ -0,0 +1,6 @@ +DROP TABLE sso_nonce; + +CREATE TABLE sso_nonce ( + nonce CHAR(36) NOT NULL PRIMARY KEY, + created_at TIMESTAMP NOT NULL DEFAULT now() +); diff --git a/migrations/postgresql/2024-02-14-170000_add_state_to_sso_nonce/up.sql b/migrations/postgresql/2024-02-14-170000_add_state_to_sso_nonce/up.sql new file mode 100644 index 0000000000..f7402460e5 --- /dev/null +++ b/migrations/postgresql/2024-02-14-170000_add_state_to_sso_nonce/up.sql @@ -0,0 +1,8 @@ +DROP TABLE sso_nonce; + +CREATE TABLE sso_nonce ( + state TEXT NOT NULL PRIMARY KEY, + nonce TEXT NOT NULL, + redirect_uri TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT now() +); diff --git a/migrations/postgresql/2024-02-26-170000_add_pkce_to_sso_nonce/down.sql b/migrations/postgresql/2024-02-26-170000_add_pkce_to_sso_nonce/down.sql new file mode 100644 index 0000000000..ef209a455e --- /dev/null +++ b/migrations/postgresql/2024-02-26-170000_add_pkce_to_sso_nonce/down.sql @@ -0,0 +1,8 @@ +DROP TABLE IF EXISTS sso_nonce; + +CREATE TABLE sso_nonce ( + state TEXT NOT NULL PRIMARY KEY, + nonce TEXT NOT NULL, + redirect_uri TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT now() +); diff --git a/migrations/postgresql/2024-02-26-170000_add_pkce_to_sso_nonce/up.sql b/migrations/postgresql/2024-02-26-170000_add_pkce_to_sso_nonce/up.sql new file mode 100644 index 0000000000..f2dedfc92e --- /dev/null +++ b/migrations/postgresql/2024-02-26-170000_add_pkce_to_sso_nonce/up.sql @@ -0,0 +1,9 @@ +DROP TABLE IF EXISTS sso_nonce; + +CREATE TABLE sso_nonce ( + state TEXT NOT NULL PRIMARY KEY, + nonce TEXT NOT NULL, + verifier TEXT, + redirect_uri TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT now() +); diff --git a/migrations/postgresql/2024-03-06-170000_add_sso_users/down.sql b/migrations/postgresql/2024-03-06-170000_add_sso_users/down.sql new file mode 100644 index 0000000000..f2f92f6822 --- /dev/null +++ b/migrations/postgresql/2024-03-06-170000_add_sso_users/down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS sso_users; diff --git a/migrations/postgresql/2024-03-06-170000_add_sso_users/up.sql b/migrations/postgresql/2024-03-06-170000_add_sso_users/up.sql new file mode 100644 index 0000000000..b74b57285f --- /dev/null +++ b/migrations/postgresql/2024-03-06-170000_add_sso_users/up.sql @@ -0,0 +1,7 @@ +CREATE TABLE sso_users ( + user_uuid CHAR(36) NOT NULL PRIMARY KEY, + identifier TEXT NOT NULL UNIQUE, + created_at TIMESTAMP NOT NULL DEFAULT now(), + + FOREIGN KEY(user_uuid) REFERENCES users(uuid) +); diff --git a/migrations/postgresql/2024-03-13-170000_sso_users_cascade/down.sql b/migrations/postgresql/2024-03-13-170000_sso_users_cascade/down.sql new file mode 100644 index 0000000000..e69de29bb2 diff --git a/migrations/postgresql/2024-03-13-170000_sso_users_cascade/up.sql b/migrations/postgresql/2024-03-13-170000_sso_users_cascade/up.sql new file mode 100644 index 0000000000..38f97b4de5 --- /dev/null +++ b/migrations/postgresql/2024-03-13-170000_sso_users_cascade/up.sql @@ -0,0 +1,3 @@ +ALTER TABLE sso_users + DROP CONSTRAINT "sso_users_user_uuid_fkey", + ADD CONSTRAINT "sso_users_user_uuid_fkey" FOREIGN KEY(user_uuid) REFERENCES users(uuid) ON UPDATE CASCADE ON DELETE CASCADE; diff --git a/migrations/sqlite/2023-02-01-133000_add_sso/up.sql b/migrations/sqlite/2023-02-01-133000_add_sso/up.sql deleted file mode 100644 index c10ab5cfe6..0000000000 --- a/migrations/sqlite/2023-02-01-133000_add_sso/up.sql +++ /dev/null @@ -1,3 +0,0 @@ -CREATE TABLE sso_nonce ( - nonce CHAR(36) NOT NULL PRIMARY KEY -); diff --git a/migrations/sqlite/2023-02-01-133000_add_sso/down.sql b/migrations/sqlite/2023-09-10-133000_add_sso/down.sql similarity index 100% rename from migrations/sqlite/2023-02-01-133000_add_sso/down.sql rename to migrations/sqlite/2023-09-10-133000_add_sso/down.sql diff --git a/migrations/sqlite/2023-09-10-133000_add_sso/up.sql b/migrations/sqlite/2023-09-10-133000_add_sso/up.sql new file mode 100644 index 0000000000..518664df7d --- /dev/null +++ b/migrations/sqlite/2023-09-10-133000_add_sso/up.sql @@ -0,0 +1,4 @@ +CREATE TABLE sso_nonce ( + nonce CHAR(36) NOT NULL PRIMARY KEY, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/migrations/sqlite/2023-09-14-133000_add_users_organizations_invited_by_email/down.sql b/migrations/sqlite/2023-09-14-133000_add_users_organizations_invited_by_email/down.sql new file mode 100644 index 0000000000..3a708927ed --- /dev/null +++ b/migrations/sqlite/2023-09-14-133000_add_users_organizations_invited_by_email/down.sql @@ -0,0 +1 @@ +ALTER TABLE users_organizations DROP COLUMN invited_by_email; diff --git a/migrations/sqlite/2023-09-14-133000_add_users_organizations_invited_by_email/up.sql b/migrations/sqlite/2023-09-14-133000_add_users_organizations_invited_by_email/up.sql new file mode 100644 index 0000000000..c94e1131eb --- /dev/null +++ b/migrations/sqlite/2023-09-14-133000_add_users_organizations_invited_by_email/up.sql @@ -0,0 +1 @@ +ALTER TABLE users_organizations ADD COLUMN invited_by_email TEXT DEFAULT NULL; diff --git a/migrations/sqlite/2024-02-14-170000_add_state_to_sso_nonce/down.sql b/migrations/sqlite/2024-02-14-170000_add_state_to_sso_nonce/down.sql new file mode 100644 index 0000000000..3cbd460249 --- /dev/null +++ b/migrations/sqlite/2024-02-14-170000_add_state_to_sso_nonce/down.sql @@ -0,0 +1,6 @@ +DROP TABLE sso_nonce; + +CREATE TABLE sso_nonce ( + nonce CHAR(36) NOT NULL PRIMARY KEY, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/migrations/sqlite/2024-02-14-170000_add_state_to_sso_nonce/up.sql b/migrations/sqlite/2024-02-14-170000_add_state_to_sso_nonce/up.sql new file mode 100644 index 0000000000..13e95fd84c --- /dev/null +++ b/migrations/sqlite/2024-02-14-170000_add_state_to_sso_nonce/up.sql @@ -0,0 +1,8 @@ +DROP TABLE sso_nonce; + +CREATE TABLE sso_nonce ( + state TEXT NOT NULL PRIMARY KEY, + nonce TEXT NOT NULL, + redirect_uri TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/migrations/sqlite/2024-02-26-170000_add_pkce_to_sso_nonce/down.sql b/migrations/sqlite/2024-02-26-170000_add_pkce_to_sso_nonce/down.sql new file mode 100644 index 0000000000..e7a55bd801 --- /dev/null +++ b/migrations/sqlite/2024-02-26-170000_add_pkce_to_sso_nonce/down.sql @@ -0,0 +1,8 @@ +DROP TABLE IF EXISTS sso_nonce; + +CREATE TABLE sso_nonce ( + state TEXT NOT NULL PRIMARY KEY, + nonce TEXT NOT NULL, + redirect_uri TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/migrations/sqlite/2024-02-26-170000_add_pkce_to_sso_nonce/up.sql b/migrations/sqlite/2024-02-26-170000_add_pkce_to_sso_nonce/up.sql new file mode 100644 index 0000000000..6b55e95d34 --- /dev/null +++ b/migrations/sqlite/2024-02-26-170000_add_pkce_to_sso_nonce/up.sql @@ -0,0 +1,9 @@ +DROP TABLE IF EXISTS sso_nonce; + +CREATE TABLE sso_nonce ( + state TEXT NOT NULL PRIMARY KEY, + nonce TEXT NOT NULL, + verifier TEXT, + redirect_uri TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/migrations/sqlite/2024-03-06-170000_add_sso_users/down.sql b/migrations/sqlite/2024-03-06-170000_add_sso_users/down.sql new file mode 100644 index 0000000000..f2f92f6822 --- /dev/null +++ b/migrations/sqlite/2024-03-06-170000_add_sso_users/down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS sso_users; diff --git a/migrations/sqlite/2024-03-06-170000_add_sso_users/up.sql b/migrations/sqlite/2024-03-06-170000_add_sso_users/up.sql new file mode 100644 index 0000000000..6d015f0418 --- /dev/null +++ b/migrations/sqlite/2024-03-06-170000_add_sso_users/up.sql @@ -0,0 +1,7 @@ +CREATE TABLE sso_users ( + user_uuid CHAR(36) NOT NULL PRIMARY KEY, + identifier TEXT NOT NULL UNIQUE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY(user_uuid) REFERENCES users(uuid) +); diff --git a/migrations/sqlite/2024-03-13_170000_sso_userscascade/down.sql b/migrations/sqlite/2024-03-13_170000_sso_userscascade/down.sql new file mode 100644 index 0000000000..e69de29bb2 diff --git a/migrations/sqlite/2024-03-13_170000_sso_userscascade/up.sql b/migrations/sqlite/2024-03-13_170000_sso_userscascade/up.sql new file mode 100644 index 0000000000..53b09cf4d0 --- /dev/null +++ b/migrations/sqlite/2024-03-13_170000_sso_userscascade/up.sql @@ -0,0 +1,9 @@ +DROP TABLE IF EXISTS sso_users; + +CREATE TABLE sso_users ( + user_uuid CHAR(36) NOT NULL PRIMARY KEY, + identifier TEXT NOT NULL UNIQUE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY(user_uuid) REFERENCES users(uuid) ON UPDATE CASCADE ON DELETE CASCADE +); diff --git a/src/api/admin.rs b/src/api/admin.rs index cc902e39b9..84e716ecf9 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -294,7 +294,7 @@ async fn invite_user(data: Json, _token: AdminToken, mut conn: DbCon err_code!("User already exists", Status::Conflict.code) } - let mut user = User::new(data.email); + let mut user = User::new(data.email, None); async fn _generate_invite(user: &User, conn: &mut DbConn) -> EmptyResult { if CONFIG.mail_enabled() { diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index 172956862a..e880db6e42 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -8,7 +8,7 @@ use serde_json::Value; use crate::{ api::{ core::{log_user_event, two_factor::email}, - register_push_device, unregister_push_device, AnonymousNotify, EmptyResult, JsonResult, Notify, + register_push_device, unregister_push_device, AnonymousNotify, ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, }, auth::{decode_delete, decode_invite, decode_verify_email, ClientHeaders, Headers}, @@ -94,8 +94,7 @@ pub struct SetPasswordData { keys: Option, master_password_hash: String, master_password_hint: Option, - #[allow(dead_code)] - org_identifier: Option, + // org_identifier: Option, } #[derive(Debug, Deserialize)] @@ -105,13 +104,6 @@ struct KeysData { public_key: String, } -#[derive(Debug, Serialize, Deserialize)] -struct TokenPayload { - exp: i64, - email: String, - nonce: String, -} - /// Trims whitespace from password hints, and converts blank password hints to `None`. fn clean_password_hint(password_hint: &Option) -> Option { match password_hint { @@ -184,10 +176,7 @@ pub async fn _register(data: Json, mut conn: DbConn) -> JsonResult err!("Registration email does not match invite email") } } else if Invitation::take(&email, &mut conn).await { - for user_org in UserOrganization::find_invited_by_user(&user.uuid, &mut conn).await.iter_mut() { - user_org.status = UserOrgStatus::Accepted as i32; - user_org.save(&mut conn).await?; - } + UserOrganization::confirm_user_invitations(&user.uuid, &mut conn).await?; user } else if CONFIG.is_signup_allowed(&email) || (CONFIG.emergency_access_allowed() @@ -203,7 +192,7 @@ pub async fn _register(data: Json, mut conn: DbConn) -> JsonResult // because the vaultwarden admin can invite anyone, regardless // of other signup restrictions. if Invitation::take(&email, &mut conn).await || CONFIG.is_signup_allowed(&email) { - User::new(email.clone()) + User::new(email.clone(), None) } else { err!("Registration not allowed or user already exists") } @@ -274,7 +263,7 @@ async fn post_set_password(data: Json, headers: Headers, mut co // 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_hash); + let password_hint = clean_password_hint(&data.master_password_hint); enforce_password_hint_setting(&password_hint)?; if let Some(client_kdf_iter) = data.kdf_iterations { @@ -1046,15 +1035,30 @@ struct SecretVerificationRequest { master_password_hash: String, } +// Change the KDF Iterations if necessary +pub async fn kdf_upgrade(user: &mut User, pwd_hash: &str, conn: &mut DbConn) -> ApiResult<()> { + if user.password_iterations != CONFIG.password_iterations() { + user.password_iterations = CONFIG.password_iterations(); + user.set_password(pwd_hash, None, false, None); + + if let Err(e) = user.save(conn).await { + error!("Error updating user: {:#?}", e); + } + } + Ok(()) +} + #[post("/accounts/verify-password", data = "")] -fn verify_password(data: Json, headers: Headers) -> JsonResult { +async fn verify_password(data: Json, headers: Headers, mut conn: DbConn) -> JsonResult { let data: SecretVerificationRequest = data.into_inner(); - let user = headers.user; + let mut user = headers.user; if !user.check_valid_password(&data.master_password_hash) { err!("Invalid password") } + kdf_upgrade(&mut user, &data.master_password_hash, &mut conn).await?; + Ok(Json(json!({ "MasterPasswordPolicy": {}, // Required for SSO login with mobile apps }))) diff --git a/src/api/core/emergency_access.rs b/src/api/core/emergency_access.rs index 1c29b7748a..f887129823 100644 --- a/src/api/core/emergency_access.rs +++ b/src/api/core/emergency_access.rs @@ -239,7 +239,7 @@ async fn send_invite(data: Json, headers: Headers, mu invitation.save(&mut conn).await?; } - let mut user = User::new(email.clone()); + let mut user = User::new(email.clone(), None); user.save(&mut conn).await?; (user, true) } diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index a4b52fb576..81636dfe94 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -59,6 +59,7 @@ pub fn routes() -> Vec { list_policies, list_policies_token, list_policies_invited_user, + get_policy_master_password, get_policy, put_policy, get_organization_tax, @@ -174,7 +175,7 @@ async fn create_organization(headers: Headers, data: Json, mut conn: Db }; let org = Organization::new(data.name, data.billing_email, private_key, public_key); - let mut user_org = UserOrganization::new(headers.user.uuid, org.uuid.clone()); + let mut user_org = UserOrganization::new(headers.user.uuid, org.uuid.clone(), None); let collection = Collection::new(org.uuid.clone(), data.collection_name, None); user_org.akey = data.key; @@ -308,9 +309,13 @@ async fn get_user_collections(headers: Headers, mut conn: DbConn) -> Json })) } +// Called during the SSO enrollment +// The `_identifier` should be the harcoded value returned by `get_org_domain_sso_details` +// The returned `Id` will then be passed to `get_policy_master_password` which will mainly ignore it #[get("/organizations/<_identifier>/auto-enroll-status")] -fn get_auto_enroll_status(_identifier: String) -> JsonResult { +fn get_auto_enroll_status(_identifier: &str) -> JsonResult { Ok(Json(json!({ + "Id": "_", "ResetPasswordEnabled": false, // Not implemented }))) } @@ -790,6 +795,9 @@ async fn _get_org_details(org_id: &str, host: &str, user_uuid: &str, conn: &mut json!(ciphers_json) } +// Endpoint called when the user select SSO login (body: `{ "email": "" }`). +// Returning a Domain/Organization here allow to prefill it and prevent prompting the user +// VaultWarden sso login is not linked to Org so we set a dummy value. #[post("/organizations/domain/sso/details")] fn get_org_domain_sso_details() -> JsonResult { Ok(Json(json!({ @@ -907,7 +915,7 @@ async fn send_invite(org_id: &str, data: Json, headers: AdminHeaders 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 } @@ -924,7 +932,8 @@ async fn send_invite(org_id: &str, data: Json, headers: AdminHeaders } }; - let mut new_user = UserOrganization::new(user.uuid.clone(), String::from(org_id)); + let mut new_user = + UserOrganization::new(user.uuid.clone(), String::from(org_id), Some(headers.user.email.clone())); let access_all = data.access_all; new_user.access_all = access_all; new_user.atype = new_type; @@ -1763,17 +1772,22 @@ async fn list_policies_token(org_id: &str, token: &str, mut conn: DbConn) -> Jso }))) } +// Called during the SSO enrollment. +// Since the VW SSO flow is not linked to an organization it will be called with a dummy or undefined `org_id` #[allow(non_snake_case)] #[get("/organizations//policies/invited-user?")] -async fn list_policies_invited_user(org_id: String, userId: String, mut conn: DbConn) -> JsonResult { - // We should confirm the user is part of the organization, but unique domain_hints must be supported first. - +async fn list_policies_invited_user(org_id: &str, userId: &str, mut conn: DbConn) -> JsonResult { if userId.is_empty() { err!("userId must not be empty"); } - let policies = OrgPolicy::find_by_org(&org_id, &mut conn).await; - let policies_json: Vec = policies.iter().map(OrgPolicy::to_json).collect(); + let user_orgs = UserOrganization::find_invited_by_user(userId, &mut conn).await; + let policies_json: Vec = if user_orgs.into_iter().any(|user_org| user_org.org_uuid == org_id) { + let policies = OrgPolicy::find_by_org(org_id, &mut conn).await; + policies.iter().map(OrgPolicy::to_json).collect() + } else { + Vec::with_capacity(0) + }; Ok(Json(json!({ "Data": policies_json, @@ -1782,7 +1796,26 @@ async fn list_policies_invited_user(org_id: String, userId: String, mut conn: Db }))) } -#[get("/organizations//policies/")] +// Called during the SSO enrollment. +#[get("/organizations//policies/master-password", rank = 1)] +fn get_policy_master_password(org_id: &str, _headers: Headers) -> JsonResult { + let data = match CONFIG.sso_master_password_policy() { + Some(policy) => policy, + None => "null".to_string(), + }; + + let policy = OrgPolicy { + uuid: String::from(org_id), + org_uuid: String::from(org_id), + atype: OrgPolicyType::MasterPassword as i32, + enabled: CONFIG.sso_master_password_policy().is_some(), + data, + }; + + Ok(Json(policy.to_json())) +} + +#[get("/organizations//policies/", rank = 2)] async fn get_policy(org_id: &str, pol_type: i32, _headers: AdminHeaders, mut conn: DbConn) -> JsonResult { let pol_type_enum = match OrgPolicyType::from_i32(pol_type) { Some(pt) => pt, @@ -2050,7 +2083,8 @@ async fn import(org_id: &str, data: Json, headers: Headers, mut c UserOrgStatus::Accepted as i32 // Automatically mark user as accepted if no email invites }; - let mut new_org_user = UserOrganization::new(user.uuid.clone(), String::from(org_id)); + let mut new_org_user = + UserOrganization::new(user.uuid.clone(), String::from(org_id), Some(headers.user.email.clone())); new_org_user.access_all = false; new_org_user.atype = UserOrgType::User as i32; new_org_user.status = user_org_status; diff --git a/src/api/core/public.rs b/src/api/core/public.rs index 737d30dd34..ed22db7227 100644 --- a/src/api/core/public.rs +++ b/src/api/core/public.rs @@ -93,7 +93,7 @@ async fn ldap_import(data: Json, token: PublicToken, mut conn: Db Some(user) => user, // exists in vaultwarden None => { // User does not exist yet - let mut new_user = User::new(user_data.email.clone()); + let mut new_user = User::new(user_data.email.clone(), None); new_user.save(&mut conn).await?; if !CONFIG.mail_enabled() { @@ -109,7 +109,12 @@ async fn ldap_import(data: Json, token: PublicToken, mut conn: Db UserOrgStatus::Accepted as i32 // Automatically mark user as accepted if no email invites }; - let mut new_org_user = UserOrganization::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_org_user = UserOrganization::new(user.uuid.clone(), org_id.clone(), Some(org_email.clone())); new_org_user.set_external_id(Some(user_data.external_id.clone())); new_org_user.access_all = false; new_org_user.atype = UserOrgType::User as i32; @@ -118,11 +123,6 @@ async fn ldap_import(data: Json, token: PublicToken, mut conn: Db new_org_user.save(&mut conn).await?; if CONFIG.mail_enabled() { - let (org_name, org_email) = match Organization::find_by_uuid(&org_id, &mut conn).await { - Some(org) => (org.name, org.billing_email), - None => err!("Error looking up organization"), - }; - mail::send_invite(&user, Some(org_id.clone()), Some(new_org_user.uuid), &org_name, Some(org_email)) .await?; } diff --git a/src/api/identity.rs b/src/api/identity.rs index b1d0cc16a4..a4e21dec2e 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -1,10 +1,10 @@ -use chrono::Utc; -use jsonwebtoken::DecodingKey; +use chrono::{NaiveDateTime, Utc}; use num_traits::FromPrimitive; -use rocket::serde::json::Json; use rocket::{ form::{Form, FromForm}, - http::CookieJar, + http::Status, + response::Redirect, + serde::json::Json, Route, }; use serde_json::Value; @@ -12,23 +12,22 @@ use serde_json::Value; use crate::{ api::{ core::{ - accounts::{PreloginData, RegisterData, _prelogin, _register}, + accounts::{PreloginData, RegisterData, _prelogin, _register, kdf_upgrade}, log_user_event, two_factor::{authenticator, duo, duo_oidc, email, enforce_2fa_policy, webauthn, yubikey}, }, push::register_push_device, ApiResult, EmptyResult, JsonResult, }, - auth::{encode_jwt, generate_organization_api_key_login_claims, generate_ssotoken_claims, ClientHeaders, ClientIp}, + auth, + auth::{AuthMethod, AuthMethodScope, ClientHeaders, ClientIp}, db::{models::*, DbConn}, error::MapResult, - mail, util, - util::{CookieManager, CustomRedirect}, - CONFIG, + mail, sso, util, CONFIG, }; pub fn routes() -> Vec { - routes![login, prelogin, identity_register, prevalidate, authorize, oidcsignin] + routes![login, prelogin, identity_register, _prevalidate, prevalidate, authorize, oidcsignin, oidcsignin_error] } #[post("/connect/token", data = "")] @@ -42,6 +41,7 @@ async fn login(data: Form, client_header: ClientHeaders, mut conn: _check_is_some(&data.refresh_token, "refresh_token cannot be blank")?; _refresh_login(data, &mut conn).await } + "password" if CONFIG.sso_enabled() && CONFIG.sso_only() => err!("SSO sign-in is required"), "password" => { _check_is_some(&data.client_id, "client_id cannot be blank")?; _check_is_some(&data.password, "password cannot be blank")?; @@ -65,15 +65,17 @@ async fn login(data: Form, client_header: ClientHeaders, mut conn: _api_key_login(data, &mut user_uuid, &mut conn, &client_header.ip).await } - "authorization_code" => { + "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")?; - _authorization_login(data, &mut user_uuid, &mut conn, &client_header.ip).await + + _sso_login(data, &mut user_uuid, &mut conn, &client_header.ip).await } + "authorization_code" => err!("SSO sign-in is not available"), t => err!("Invalid type", t), }; @@ -107,172 +109,147 @@ async fn login(data: Form, client_header: ClientHeaders, mut conn: login_result } +// Return Status::Unauthorized to trigger logout async fn _refresh_login(data: ConnectData, conn: &mut DbConn) -> JsonResult { // Extract token - let token = data.refresh_token.unwrap(); - - // Get device by refresh token - let mut device = Device::find_by_refresh_token(&token, conn).await.map_res("Invalid refresh token")?; - - let scope = "api offline_access"; - let scope_vec = vec!["api".into(), "offline_access".into()]; + let refresh_token = match data.refresh_token { + Some(token) => token, + None => err_code!("Missing refresh_token", Status::Unauthorized.code), + }; - // Common - let user = User::find_by_uuid(&device.user_uuid, conn).await.unwrap(); // --- // Disabled this variable, it was used to generate the JWT // Because this might get used in the future, and is add by the Bitwarden Server, lets keep it, but then commented out // See: https://github.com/dani-garcia/vaultwarden/issues/4156 // --- // let orgs = UserOrganization::find_confirmed_by_user(&user.uuid, conn).await; - let (access_token, expires_in) = device.refresh_tokens(&user, scope_vec); - device.save(conn).await?; - - let result = json!({ - "access_token": access_token, - "expires_in": expires_in, - "token_type": "Bearer", - "refresh_token": device.refresh_token, - - "scope": scope, - }); - - Ok(Json(result)) + match auth::refresh_tokens(&refresh_token, conn).await { + Err(err) => { + err_code!(format!("Unable to refresh login credentials: {}", err.message()), Status::Unauthorized.code) + } + Ok((mut device, auth_tokens)) => { + // Save to update `device.updated_at` to track usage + device.save(conn).await?; + + let result = json!({ + "refresh_token": auth_tokens.refresh_token(), + "access_token": auth_tokens.access_token(), + "expires_in": auth_tokens.expires_in(), + "token_type": "Bearer", + "scope": auth_tokens.scope(), + }); + + Ok(Json(result)) + } + } } -#[derive(Debug, Serialize, Deserialize)] -struct TokenPayload { - exp: i64, - email: Option, - nonce: String, -} +// After exchanging the code we need to check first if 2FA is needed before continuing +async fn _sso_login(data: ConnectData, user_uuid: &mut Option, conn: &mut DbConn, ip: &ClientIp) -> JsonResult { + AuthMethod::Sso.check_scope(data.scope.as_ref())?; -async fn _authorization_login( - data: ConnectData, - user_uuid: &mut Option, - conn: &mut DbConn, - ip: &ClientIp, -) -> JsonResult { - let scope = match data.scope.as_ref() { - None => err!("Got no scope in OIDC data"), - Some(scope) => scope, - }; - if scope != "api offline_access" { - err!("Scope not supported") - } + // Ratelimit the login + crate::ratelimit::check_limit_login(&ip.ip)?; - let scope_vec = vec!["api".into(), "offline_access".into()]; let code = match data.code.as_ref() { None => err!("Got no code in OIDC data"), Some(code) => code, }; - let (refresh_token, id_token, user_info) = match get_auth_code_access_token(code).await { - Ok((refresh_token, id_token, user_info)) => (refresh_token, id_token, user_info), - Err(_err) => err!("Could not retrieve access token"), - }; - - let mut validation = jsonwebtoken::Validation::default(); - validation.insecure_disable_signature_validation(); - - let token = - match jsonwebtoken::decode::(id_token.as_str(), &DecodingKey::from_secret(&[]), &validation) { - Err(_err) => err!("Could not decode id token"), - Ok(payload) => payload.claims, - }; + let user_infos = sso::exchange_code(code, conn).await?; + + // Will trigger 2FA flow if needed + let user_data = match SsoUser::find_by_identifier_or_email(&user_infos.identifier, &user_infos.email, conn).await { + None => None, + 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") + } + Some((user, Some(sso_user))) if sso_user.identifier != user_infos.identifier => { + error!( + "Login failure ({}), existing SSO user ({}) with same email ({})", + user_infos.identifier, user.uuid, user.email + ); + err_silent!("Existing SSO user with same email") + } + Some((user, sso_user)) => { + let (mut device, new_device) = get_device(&data, conn, &user).await?; + let twofactor_token = twofactor_auth(&user, &data, &mut device, ip, conn).await?; - // let expiry = token.exp; - let nonce = token.nonce; - let mut new_user = false; - - match SsoNonce::find(&nonce, conn).await { - Some(sso_nonce) => { - match sso_nonce.delete(conn).await { - Ok(_) => { - let user_email = match token.email { - Some(email) => email, - None => match user_info.email() { - None => err!("Neither id token nor userinfo contained an email"), - Some(email) => email.to_owned().to_string(), - }, - }; - let now = Utc::now().naive_utc(); - - let mut user = match User::find_by_mail(&user_email, conn).await { - Some(user) => user, - None => { - new_user = true; - User::new(user_email.clone()) - } - }; - - if new_user { - user.verified_at = Some(Utc::now().naive_utc()); - user.save(conn).await?; - } + Some((user, device, new_device, twofactor_token, sso_user)) + } + }; - // Set the user_uuid here to be passed back used for event logging. - *user_uuid = Some(user.uuid.clone()); + // We passed 2FA get full user informations + let auth_user = sso::redeem(&user_infos.state, conn).await?; - let (mut device, new_device) = get_device(&data, conn, &user).await; + let now = Utc::now().naive_utc(); + let (user, mut device, new_device, twofactor_token, sso_user) = match user_data { + None => { + if !CONFIG.is_email_domain_allowed(&user_infos.email) { + err!("Email domain not allowed"); + } - let twofactor_token = twofactor_auth(&user, &data, &mut device, ip, true, conn).await?; + if !user_infos.email_verified.unwrap_or(true) { + err!("Email needs to be verified before you can use VaultWarden"); + } - if CONFIG.mail_enabled() && new_device { - if let Err(e) = - mail::send_new_device_logged_in(&user.email, &ip.ip.to_string(), &now, &device.name).await - { - error!("Error sending new device email: {:#?}", e); + let mut user = User::new(user_infos.email, user_infos.user_name); + user.verified_at = Some(now); + user.save(conn).await?; - if CONFIG.require_device_email() { - err!("Could not send login notification email. Please contact your administrator.") - } - } - } + let (device, new_device) = get_device(&data, conn, &user).await?; - if CONFIG.sso_acceptall_invites() { - for user_org in UserOrganization::find_invited_by_user(&user.uuid, conn).await.iter_mut() { - user_org.status = UserOrgStatus::Accepted as i32; - user_org.save(conn).await?; - } - } + (user, device, new_device, None, None) + } + Some((mut user, device, new_device, twofactor_token, sso_user)) 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; + } - device.refresh_token = refresh_token.clone(); - device.save(conn).await?; - - let (access_token, expires_in) = device.refresh_tokens(&user, scope_vec); - device.save(conn).await?; - - let mut result = json!({ - "access_token": access_token, - "token_type": "Bearer", - "refresh_token": device.refresh_token, - "expires_in": expires_in, - "Key": user.akey, - "PrivateKey": user.private_key, - "Kdf": user.client_kdf_type, - "KdfIterations": user.client_kdf_iter, - "KdfMemory": user.client_kdf_memory, - "KdfParallelism": user.client_kdf_parallelism, - "ResetMasterPassword": user.password_hash.is_empty(), - "scope": scope, - "unofficialServer": true, - }); - - if let Some(token) = twofactor_token { - result["TwoFactorToken"] = Value::String(token); - } + if !CONFIG.mail_enabled() { + UserOrganization::confirm_user_invitations(&user.uuid, conn).await?; + } - info!("User {} logged in successfully. IP: {}", user.email, ip.ip); - Ok(Json(result)) + user.save(conn).await?; + (user, device, new_device, twofactor_token, sso_user) + } + Some((user, device, new_device, twofactor_token, sso_user)) => { + if user.email != user_infos.email { + if CONFIG.mail_enabled() { + mail::send_sso_change_email(&user_infos.email).await?; } - Err(_) => err!("Failed to delete nonce"), + info!("User {} email changed in SSO provider from {} to {}", user.uuid, user.email, user_infos.email); } + (user, device, new_device, twofactor_token, sso_user) } - None => { - err!("Invalid nonce") - } + }; + + 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_uuid = Some(user.uuid.clone()); + + let auth_tokens = sso::create_auth_tokens( + &device, + &user, + auth_user.refresh_token, + &auth_user.access_token, + auth_user.expires_in, + )?; + + authenticated_response(&user, &mut device, new_device, auth_tokens, twofactor_token, &now, conn, ip).await } #[derive(Default, Deserialize, Serialize)] @@ -294,19 +271,11 @@ async fn _password_login( ip: &ClientIp, ) -> JsonResult { // Validate scope - let scope = data.scope.as_ref().unwrap(); - if scope != "api offline_access" { - err!("Scope not supported") - } - let scope_vec = vec!["api".into(), "offline_access".into()]; + AuthMethod::Password.check_scope(data.scope.as_ref())?; // Ratelimit the login crate::ratelimit::check_limit_login(&ip.ip)?; - if CONFIG.sso_enabled() && CONFIG.sso_only() { - err!("SSO sign-in is required"); - } - // Get the user let username = data.username.as_ref().unwrap().trim(); let mut user = match User::find_by_mail(username, conn).await { @@ -370,13 +339,8 @@ async fn _password_login( } // Change the KDF Iterations (only when not logging in with an auth request) - if data.auth_request.is_none() && user.password_iterations != CONFIG.password_iterations() { - user.password_iterations = CONFIG.password_iterations(); - user.set_password(password, None, false, None); - - if let Err(e) = user.save(conn).await { - error!("Error updating user: {:#?}", e); - } + if data.auth_request.is_none() { + kdf_upgrade(&mut user, password, conn).await?; } let now = Utc::now().naive_utc(); @@ -413,12 +377,28 @@ async fn _password_login( ) } - let (mut device, new_device) = get_device(&data, conn, &user).await; + let (mut device, new_device) = get_device(&data, conn, &user).await?; - let twofactor_token = twofactor_auth(&user, &data, &mut device, ip, false, conn).await?; + let twofactor_token = twofactor_auth(&user, &data, &mut device, ip, conn).await?; + let auth_tokens = auth::AuthTokens::new(&device, &user, AuthMethod::Password); + + authenticated_response(&user, &mut device, new_device, auth_tokens, twofactor_token, &now, conn, ip).await +} + +#[allow(clippy::too_many_arguments)] +async fn authenticated_response( + user: &User, + device: &mut Device, + new_device: bool, + auth_tokens: auth::AuthTokens, + twofactor_token: Option, + now: &NaiveDateTime, + conn: &mut DbConn, + ip: &ClientIp, +) -> JsonResult { if CONFIG.mail_enabled() && new_device { - if let Err(e) = mail::send_new_device_logged_in(&user.email, &ip.ip.to_string(), &now, &device).await { + if let Err(e) = mail::send_new_device_logged_in(&user.email, &ip.ip.to_string(), now, device).await { error!("Error sending new device email: {:#?}", e); if CONFIG.require_device_email() { @@ -434,17 +414,10 @@ async fn _password_login( // register push device if !new_device { - register_push_device(&mut device, conn).await?; + register_push_device(device, conn).await?; } - // Common - // --- - // Disabled this variable, it was used to generate the JWT - // Because this might get used in the future, and is add by the Bitwarden Server, lets keep it, but then commented out - // See: https://github.com/dani-garcia/vaultwarden/issues/4156 - // --- - // let orgs = UserOrganization::find_confirmed_by_user(&user.uuid, conn).await; - let (access_token, expires_in) = device.refresh_tokens(&user, scope_vec); + // Save to update `device.updated_at` to track usage device.save(conn).await?; // Fetch all valid Master Password Policies and merge them into one with all true's and larges numbers as one policy @@ -478,14 +451,11 @@ async fn _password_login( }; let mut result = json!({ - "access_token": access_token, - "expires_in": expires_in, + "access_token": auth_tokens.access_token(), + "expires_in": auth_tokens.expires_in(), "token_type": "Bearer", - "refresh_token": device.refresh_token, - "Key": user.akey, + "refresh_token": auth_tokens.refresh_token(), "PrivateKey": user.private_key, - //"TwoFactorToken": "11122233333444555666777888999" - "Kdf": user.client_kdf_type, "KdfIterations": user.client_kdf_iter, "KdfMemory": user.client_kdf_memory, @@ -493,19 +463,22 @@ async fn _password_login( "ResetMasterPassword": false, // TODO: Same as above "ForcePasswordReset": false, "MasterPasswordPolicy": master_password_policy, - - "scope": scope, + "scope": auth_tokens.scope(), "UserDecryptionOptions": { "HasMasterPassword": !user.password_hash.is_empty(), "Object": "userDecryptionOptions" }, }); + if !user.akey.is_empty() { + result["Key"] = Value::String(user.akey.clone()); + } + if let Some(token) = twofactor_token { result["TwoFactorToken"] = Value::String(token); } - info!("User {} logged in successfully. IP: {}", username, ip.ip); + info!("User {} logged in successfully. IP: {}", user.email, ip.ip); Ok(Json(result)) } @@ -519,9 +492,9 @@ async fn _api_key_login( crate::ratelimit::check_limit_login(&ip.ip)?; // Validate scope - match data.scope.as_ref().unwrap().as_ref() { - "api" => _user_api_key_login(data, user_uuid, conn, ip).await, - "api.organization" => _organization_api_key_login(data, conn, ip).await, + match data.scope.as_ref() { + Some(scope) if scope == &AuthMethod::UserApiKey.scope() => _user_api_key_login(data, user_uuid, conn, ip).await, + Some(scope) if scope == &AuthMethod::OrgApiKey.scope() => _organization_api_key_login(data, conn, ip).await, _ => err!("Scope not supported"), } } @@ -569,7 +542,7 @@ async fn _user_api_key_login( ) } - let (mut device, new_device) = get_device(&data, conn, &user).await; + let (mut device, new_device) = get_device(&data, conn, &user).await?; if CONFIG.mail_enabled() && new_device { let now = Utc::now().naive_utc(); @@ -587,15 +560,15 @@ async fn _user_api_key_login( } } - // Common - let scope_vec = vec!["api".into()]; // --- // Disabled this variable, it was used to generate the JWT // Because this might get used in the future, and is add by the Bitwarden Server, lets keep it, but then commented out // See: https://github.com/dani-garcia/vaultwarden/issues/4156 // --- // let orgs = UserOrganization::find_confirmed_by_user(&user.uuid, conn).await; - let (access_token, expires_in) = device.refresh_tokens(&user, scope_vec); + let access_claims = auth::LoginJwtClaims::default(&device, &user, &AuthMethod::UserApiKey); + + // Save to update `device.updated_at` to track usage device.save(conn).await?; info!("User {} logged in successfully via API key. IP: {}", user.email, ip.ip); @@ -603,8 +576,8 @@ async fn _user_api_key_login( // Note: No refresh_token is returned. The CLI just repeats the // client_credentials login flow when the existing token expires. let result = json!({ - "access_token": access_token, - "expires_in": expires_in, + "access_token": access_claims.token(), + "expires_in": access_claims.expires_in(), "token_type": "Bearer", "Key": user.akey, "PrivateKey": user.private_key, @@ -614,7 +587,7 @@ async fn _user_api_key_login( "KdfMemory": user.client_kdf_memory, "KdfParallelism": user.client_kdf_parallelism, "ResetMasterPassword": false, // TODO: according to official server seems something like: user.password_hash.is_empty(), but would need testing - "scope": "api", + "scope": AuthMethod::UserApiKey.scope(), }); Ok(Json(result)) @@ -638,19 +611,19 @@ async fn _organization_api_key_login(data: ConnectData, conn: &mut DbConn, ip: & err!("Incorrect client_secret", format!("IP: {}. Organization: {}.", ip.ip, org_api_key.org_uuid)) } - let claim = generate_organization_api_key_login_claims(org_api_key.uuid, org_api_key.org_uuid); - let access_token = crate::auth::encode_jwt(&claim); + let claim = auth::generate_organization_api_key_login_claims(org_api_key.uuid, org_api_key.org_uuid); + let access_token = auth::encode_jwt(&claim); Ok(Json(json!({ "access_token": access_token, "expires_in": 3600, "token_type": "Bearer", - "scope": "api.organization", + "scope": AuthMethod::OrgApiKey.scope(), }))) } /// Retrieves an existing device or creates a new device from ConnectData and the User -async fn get_device(data: &ConnectData, conn: &mut DbConn, user: &User) -> (Device, bool) { +async fn get_device(data: &ConnectData, conn: &mut DbConn, user: &User) -> ApiResult<(Device, bool)> { // On iOS, device_type sends "iOS", on others it sends a number // When unknown or unable to parse, return 14, which is 'Unknown Browser' let device_type = util::try_parse_string(data.device_type.as_ref()).unwrap_or(14); @@ -662,12 +635,13 @@ async fn get_device(data: &ConnectData, conn: &mut DbConn, user: &User) -> (Devi let device = match Device::find_by_uuid_and_user(&device_id, &user.uuid, conn).await { Some(device) => device, None => { + let device = Device::new(device_id, user.uuid.clone(), device_name, device_type); new_device = true; - Device::new(device_id, user.uuid.clone(), device_name, device_type) + device } }; - (device, new_device) + Ok((device, new_device)) } async fn twofactor_auth( @@ -675,7 +649,6 @@ async fn twofactor_auth( data: &ConnectData, device: &mut Device, ip: &ClientIp, - is_sso: bool, conn: &mut DbConn, ) -> ApiResult> { let twofactors = TwoFactor::find_by_user(&user.uuid, conn).await; @@ -693,17 +666,7 @@ async fn twofactor_auth( let twofactor_code = match data.two_factor_token { Some(ref code) => code, - None => { - if is_sso { - if CONFIG.sso_only() { - err!("2FA not supported with SSO login, contact your administrator"); - } else { - err!("2FA not supported with SSO login, log in directly using email and master password"); - } - } else { - err_json!(_json_err_twofactor(&twofactor_ids, &user.uuid, data, conn).await?, "2FA token not provided"); - } - } + None => err_json!(_json_err_twofactor(&twofactor_ids, &user.uuid, data, conn).await?, "2FA token not provided"), }; let selected_twofactor = twofactors.into_iter().find(|tf| tf.atype == selected_id && tf.enabled); @@ -765,12 +728,13 @@ async fn twofactor_auth( TwoFactorIncomplete::mark_complete(&user.uuid, &device.uuid, conn).await?; - if !CONFIG.disable_2fa_remember() && remember == 1 { - Ok(Some(device.refresh_twofactor_remember())) + let two_factor = if !CONFIG.disable_2fa_remember() && remember == 1 { + Some(device.refresh_twofactor_remember()) } else { device.delete_twofactor_remember(); - Ok(None) - } + None + }; + Ok(two_factor) } fn _selected_data(tf: Option) -> ApiResult { @@ -947,176 +911,120 @@ fn _check_is_some(value: &Option, msg: &str) -> EmptyResult { Ok(()) } +// Deprecated but still needed for Mobile apps #[get("/account/prevalidate")] -#[allow(non_snake_case)] -fn prevalidate() -> JsonResult { - let claims = generate_ssotoken_claims(); - let ssotoken = encode_jwt(&claims); - Ok(Json(json!({ - "token": ssotoken, - }))) +fn _prevalidate() -> JsonResult { + prevalidate() } -use openidconnect::core::{CoreClient, CoreProviderMetadata, CoreResponseType, CoreUserInfoClaims}; -use openidconnect::reqwest::async_http_client; -use openidconnect::{ - AuthenticationFlow, AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, Nonce, OAuth2TokenResponse, - RedirectUrl, Scope, -}; - -async fn get_client_from_sso_config() -> ApiResult { - let redirect = CONFIG.sso_callback_path(); - let client_id = ClientId::new(CONFIG.sso_client_id()); - let client_secret = ClientSecret::new(CONFIG.sso_client_secret()); - let issuer_url = match IssuerUrl::new(CONFIG.sso_authority()) { - Ok(issuer) => issuer, - Err(_err) => err!("invalid issuer URL"), - }; - - let provider_metadata = match CoreProviderMetadata::discover_async(issuer_url, async_http_client).await { - Ok(metadata) => metadata, - Err(_err) => { - err!("Failed to discover OpenID provider") - } - }; +#[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") + } +} - let redirect_uri = match RedirectUrl::new(redirect) { - Ok(uri) => uri, - Err(err) => err!("Invalid redirection url: {}", err.to_string()), - }; - let client = CoreClient::from_provider_metadata(provider_metadata, client_id, Some(client_secret)) - .set_redirect_uri(redirect_uri); +#[get("/connect/oidc-signin?&", rank = 1)] +async fn oidcsignin(code: String, state: String, conn: DbConn) -> ApiResult { + oidcsignin_redirect( + state.clone(), + sso::OIDCCodeWrapper::Ok { + code, + state, + }, + &conn, + ) + .await +} - Ok(client) +// Bitwarden client appear to only care for code and state so we pipe it through +// cf: https://github.com/bitwarden/clients/blob/8e46ef1ae5be8b62b0d3d0b9d1b1c62088a04638/libs/angular/src/auth/components/sso.component.ts#L68C11-L68C23) +#[get("/connect/oidc-signin?&&", rank = 2)] +async fn oidcsignin_error( + state: String, + error: String, + error_description: Option, + conn: DbConn, +) -> ApiResult { + oidcsignin_redirect( + state.clone(), + sso::OIDCCodeWrapper::Error { + state, + error, + error_description, + }, + &conn, + ) + .await } -#[get("/connect/oidc-signin?")] -fn oidcsignin(code: String, jar: &CookieJar<'_>, _conn: DbConn) -> ApiResult { - let cookiemanager = CookieManager::new(jar); +// iss and scope parameters are needed for redirection to work on IOS. +async fn oidcsignin_redirect(state: String, wrapper: sso::OIDCCodeWrapper, conn: &DbConn) -> ApiResult { + let code = sso::encode_code_claims(wrapper); - let redirect_uri = match cookiemanager.get_cookie("redirect_uri".to_string()) { - None => err!("No redirect_uri in cookie"), - Some(uri) => uri, + let nonce = match SsoNonce::find(&state, conn).await { + Some(n) => n, + None => err!(format!("Failed to retrive redirect_uri with {state}")), }; - let orig_state = match cookiemanager.get_cookie("state".to_string()) { - None => err!("No state in cookie"), - Some(state) => 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)), }; - cookiemanager.delete_cookie("redirect_uri".to_string()); - cookiemanager.delete_cookie("state".to_string()); + url.query_pairs_mut() + .append_pair("code", &code) + .append_pair("state", &state) + .append_pair("scope", &AuthMethod::Sso.scope()) + .append_pair("iss", &CONFIG.domain()); - let redirect = CustomRedirect { - url: format!("{redirect_uri}?code={code}&state={orig_state}"), - headers: vec![], - }; + debug!("Redirection to {url}"); - Ok(redirect) + Ok(Redirect::temporary(String::from(url))) } -#[derive(FromForm)] -#[allow(non_snake_case)] +#[derive(Debug, Clone, Default, FromForm)] struct AuthorizeData { - #[allow(unused)] #[field(name = uncased("client_id"))] #[field(name = uncased("clientid"))] - client_id: Option, + client_id: String, #[field(name = uncased("redirect_uri"))] #[field(name = uncased("redirecturi"))] - redirect_uri: Option, + redirect_uri: String, #[allow(unused)] - #[field(name = uncased("response_type"))] - #[field(name = uncased("responsetype"))] response_type: Option, #[allow(unused)] - #[field(name = uncased("scope"))] scope: Option, - #[field(name = uncased("state"))] - state: Option, + state: String, #[allow(unused)] - #[field(name = uncased("code_challenge"))] code_challenge: Option, #[allow(unused)] - #[field(name = uncased("code_challenge_method"))] code_challenge_method: Option, #[allow(unused)] - #[field(name = uncased("response_mode"))] response_mode: Option, #[allow(unused)] - #[field(name = uncased("domain_hint"))] domain_hint: Option, #[allow(unused)] #[field(name = uncased("ssoToken"))] - ssoToken: Option, + sso_token: Option, } +// The `redirect_uri` will change depending of the client (web, android, ios ..) #[get("/connect/authorize?")] -async fn authorize(data: AuthorizeData, jar: &CookieJar<'_>, mut conn: DbConn) -> ApiResult { - let cookiemanager = CookieManager::new(jar); - match get_client_from_sso_config().await { - Ok(client) => { - let (auth_url, _csrf_state, nonce) = client - .authorize_url( - AuthenticationFlow::::AuthorizationCode, - CsrfToken::new_random, - Nonce::new_random, - ) - .add_scope(Scope::new("email".to_string())) - .add_scope(Scope::new("profile".to_string())) - .url(); - - let sso_nonce = SsoNonce::new(nonce.secret().to_string()); - sso_nonce.save(&mut conn).await?; - - let redirect_uri = match data.redirect_uri { - None => err!("No redirect_uri in data"), - Some(uri) => uri, - }; - cookiemanager.set_cookie("redirect_uri".to_string(), redirect_uri); - let state = match data.state { - None => err!("No state in data"), - Some(state) => state, - }; - cookiemanager.set_cookie("state".to_string(), state); - - let redirect = CustomRedirect { - url: format!("{}", auth_url), - headers: vec![], - }; - - Ok(redirect) - } - Err(_err) => err!("Unable to find client from identifier"), - } -} - -async fn get_auth_code_access_token(code: &str) -> ApiResult<(String, String, CoreUserInfoClaims)> { - let oidc_code = AuthorizationCode::new(String::from(code)); - match get_client_from_sso_config().await { - Ok(client) => match client.exchange_code(oidc_code).request_async(async_http_client).await { - Ok(token_response) => { - let refresh_token = match token_response.refresh_token() { - Some(token) => token.secret().to_string(), - None => String::new(), - }; - let id_token = match token_response.extra_fields().id_token() { - None => err!("Token response did not contain an id_token"), - Some(token) => token.to_string(), - }; +async fn authorize(data: AuthorizeData, conn: DbConn) -> ApiResult { + let AuthorizeData { + client_id, + redirect_uri, + state, + .. + } = data; - let user_info: CoreUserInfoClaims = - match client.user_info(token_response.access_token().to_owned(), None) { - Err(_err) => err!("Token response did not contain user_info"), - Ok(info) => match info.request_async(async_http_client).await { - Err(_err) => err!("Request to user_info endpoint failed"), - Ok(claim) => claim, - }, - }; + let auth_url = sso::authorize_url(state, &client_id, &redirect_uri, conn).await?; - Ok((refresh_token, id_token, user_info)) - } - Err(err) => err!("Failed to contact token endpoint: {}", err.to_string()), - }, - Err(_err) => err!("Unable to find client"), - } + Ok(Redirect::temporary(String::from(auth_url))) } diff --git a/src/api/mod.rs b/src/api/mod.rs index 27a3775fbf..16d13ccb58 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -35,7 +35,7 @@ pub use crate::api::{ use crate::db::{models::User, DbConn}; // Type aliases for API methods results -type ApiResult = Result; +pub type ApiResult = Result; pub type JsonResult = ApiResult>; pub type EmptyResult = ApiResult<()>; diff --git a/src/auth.rs b/src/auth.rs index f07a9461e5..4b5ecb9ed8 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,6 +1,5 @@ // JWT Handling -// -use chrono::{TimeDelta, Utc}; +use chrono::{DateTime, TimeDelta, Utc}; use jsonwebtoken::{errors::ErrorKind, Algorithm, DecodingKey, EncodingKey, Header}; use num_traits::FromPrimitive; use once_cell::sync::{Lazy, OnceCell}; @@ -14,18 +13,29 @@ use std::{ net::IpAddr, }; -use crate::{error::Error, CONFIG}; +use crate::{ + api::ApiResult, + db::{ + models::{Collection, Device, User, UserOrgStatus, UserOrgType, UserOrganization, UserStampException}, + DbConn, + }, + error::Error, + sso, CONFIG, +}; const JWT_ALGORITHM: Algorithm = Algorithm::RS256; -pub static DEFAULT_VALIDITY: Lazy = Lazy::new(|| TimeDelta::try_hours(2).unwrap()); +// Limit when BitWarden consider the token as expired +pub static BW_EXPIRATION: Lazy = Lazy::new(|| TimeDelta::try_minutes(5).unwrap()); + +pub static DEFAULT_REFRESH_VALIDITY: Lazy = Lazy::new(|| TimeDelta::try_days(30).unwrap()); +pub static DEFAULT_ACCESS_VALIDITY: Lazy = Lazy::new(|| TimeDelta::try_hours(2).unwrap()); static JWT_HEADER: Lazy
= Lazy::new(|| Header::new(JWT_ALGORITHM)); pub static JWT_LOGIN_ISSUER: Lazy = Lazy::new(|| format!("{}|login", CONFIG.domain_origin())); static JWT_INVITE_ISSUER: Lazy = Lazy::new(|| format!("{}|invite", CONFIG.domain_origin())); static JWT_EMERGENCY_ACCESS_INVITE_ISSUER: Lazy = Lazy::new(|| format!("{}|emergencyaccessinvite", CONFIG.domain_origin())); -static JWT_SSOTOKEN_ISSUER: Lazy = Lazy::new(|| format!("{}|ssotoken", CONFIG.domain_origin())); static JWT_DELETE_ISSUER: Lazy = Lazy::new(|| format!("{}|delete", CONFIG.domain_origin())); static JWT_VERIFYEMAIL_ISSUER: Lazy = Lazy::new(|| format!("{}|verifyemail", CONFIG.domain_origin())); static JWT_ADMIN_ISSUER: Lazy = Lazy::new(|| format!("{}|admin", CONFIG.domain_origin())); @@ -87,7 +97,7 @@ pub fn encode_jwt(claims: &T) -> String { } } -fn decode_jwt(token: &str, issuer: String) -> Result { +pub fn decode_jwt(token: &str, issuer: String) -> Result { let mut validation = jsonwebtoken::Validation::new(JWT_ALGORITHM); validation.leeway = 30; // 30 seconds validation.validate_exp = true; @@ -106,6 +116,10 @@ fn decode_jwt(token: &str, issuer: String) -> Result Result { + decode_jwt(token, JWT_LOGIN_ISSUER.to_string()) +} + pub fn decode_login(token: &str) -> Result { decode_jwt(token, JWT_LOGIN_ISSUER.to_string()) } @@ -179,6 +193,73 @@ pub struct LoginJwtClaims { pub amr: Vec, } +impl LoginJwtClaims { + pub fn new(device: &Device, user: &User, nbf: i64, exp: i64, scope: Vec, now: DateTime) -> Self { + // --- + // Disabled these keys to be added to the JWT since they could cause the JWT to get too large + // Also These key/value pairs are not used anywhere by either Vaultwarden or Bitwarden Clients + // Because these might get used in the future, and they are added by the Bitwarden Server, lets keep it, but then commented out + // --- + // fn arg: orgs: Vec, + // --- + // let orgowner: Vec<_> = orgs.iter().filter(|o| o.atype == 0).map(|o| o.org_uuid.clone()).collect(); + // let orgadmin: Vec<_> = orgs.iter().filter(|o| o.atype == 1).map(|o| o.org_uuid.clone()).collect(); + // let orguser: Vec<_> = orgs.iter().filter(|o| o.atype == 2).map(|o| o.org_uuid.clone()).collect(); + // let orgmanager: Vec<_> = orgs.iter().filter(|o| o.atype == 3).map(|o| o.org_uuid.clone()).collect(); + + if exp <= (now + *BW_EXPIRATION).timestamp() { + warn!("Raise access_token lifetime to more than 5min.") + } + + // Create the JWT claims struct, to send to the client + Self { + nbf, + exp, + iss: JWT_LOGIN_ISSUER.to_string(), + sub: user.uuid.clone(), + premium: true, + name: user.name.clone(), + email: user.email.clone(), + email_verified: !CONFIG.mail_enabled() || user.verified_at.is_some(), + + // --- + // Disabled these keys to be added to the JWT since they could cause the JWT to get too large + // Also These key/value pairs are not used anywhere by either Vaultwarden or Bitwarden Clients + // Because these might get used in the future, and they are added by the Bitwarden Server, lets keep it, but then commented out + // See: https://github.com/dani-garcia/vaultwarden/issues/4156 + // --- + // orgowner, + // orgadmin, + // orguser, + // orgmanager, + sstamp: user.security_stamp.clone(), + device: device.uuid.clone(), + scope, + amr: vec!["Application".into()], + } + } + + pub fn default(device: &Device, user: &User, auth_method: &AuthMethod) -> Self { + let time_now = Utc::now(); + Self::new( + device, + user, + time_now.timestamp(), + (time_now + *DEFAULT_ACCESS_VALIDITY).timestamp(), + auth_method.scope_vec(), + time_now, + ) + } + + pub fn token(&self) -> String { + encode_jwt(&self) + } + + pub fn expires_in(&self) -> i64 { + self.exp - Utc::now().timestamp() + } +} + #[derive(Debug, Serialize, Deserialize)] pub struct InviteJwtClaims { // Not before @@ -332,28 +413,6 @@ pub fn generate_delete_claims(uuid: String) -> BasicJwtClaims { } } -#[derive(Debug, Serialize, Deserialize)] -pub struct SsoTokenJwtClaims { - // Not before - pub nbf: i64, - // Expiration time - pub exp: i64, - // Issuer - pub iss: String, - // Subject - pub sub: String, -} - -pub fn generate_ssotoken_claims() -> SsoTokenJwtClaims { - let time_now = Utc::now().naive_utc(); - SsoTokenJwtClaims { - nbf: time_now.timestamp(), - exp: (time_now + Duration::minutes(2)).timestamp(), - iss: JWT_SSOTOKEN_ISSUER.to_string(), - sub: "vaultwarden".to_string(), - } -} - pub fn generate_verify_email_claims(uuid: String) -> BasicJwtClaims { let time_now = Utc::now(); let expire_hours = i64::from(CONFIG.invitation_expiration_hours()); @@ -393,11 +452,6 @@ use rocket::{ request::{FromRequest, Outcome, Request}, }; -use crate::db::{ - models::{Collection, Device, User, UserOrgStatus, UserOrgType, UserOrganization, UserStampException}, - DbConn, -}; - pub struct Host { pub host: String, } @@ -941,3 +995,150 @@ impl<'r> FromRequest<'r> for ClientVersion { Outcome::Success(ClientVersion(version)) } } + +#[derive(Clone, Debug, Ord, PartialOrd, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AuthMethod { + OrgApiKey, + Password, + Sso, + UserApiKey, +} + +pub trait AuthMethodScope { + fn scope_vec(&self) -> Vec; + fn scope(&self) -> String; + fn check_scope(&self, scope: Option<&String>) -> ApiResult; +} + +impl AuthMethodScope for AuthMethod { + 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(), + } + } + + fn scope_vec(&self) -> Vec { + self.scope().split_whitespace().map(str::to_string).collect() + } + + fn check_scope(&self, scope: Option<&String>) -> ApiResult { + let method_scope = self.scope(); + match scope { + None => err!("Missing scope"), + Some(scope) if scope == &method_scope => Ok(method_scope), + Some(scope) => err!(format!("Scope ({scope}) not supported")), + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub enum TokenWrapper { + Access(String), + Refresh(String), +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct RefreshJwtClaims { + // Not before + pub nbf: i64, + // Expiration time + pub exp: i64, + // Issuer + pub iss: String, + // Subject + pub sub: AuthMethod, + + pub device_token: String, + + pub token: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct AuthTokens { + pub refresh_claims: RefreshJwtClaims, + pub access_claims: LoginJwtClaims, +} + +impl AuthTokens { + pub fn refresh_token(&self) -> String { + encode_jwt(&self.refresh_claims) + } + + pub fn access_token(&self) -> String { + self.access_claims.token() + } + + pub fn expires_in(&self) -> i64 { + self.access_claims.expires_in() + } + + pub fn scope(&self) -> String { + self.refresh_claims.sub.scope() + } + + // Create refresh_token and access_token with default validity + pub fn new(device: &Device, user: &User, sub: AuthMethod) -> Self { + let time_now = Utc::now(); + + let access_claims = LoginJwtClaims::default(device, user, &sub); + + let refresh_claims = RefreshJwtClaims { + nbf: time_now.timestamp(), + exp: (time_now + *DEFAULT_REFRESH_VALIDITY).timestamp(), + iss: JWT_LOGIN_ISSUER.to_string(), + sub, + device_token: device.refresh_token.clone(), + token: None, + }; + + Self { + refresh_claims, + access_claims, + } + } +} + +pub async fn refresh_tokens(refresh_token: &str, conn: &mut DbConn) -> ApiResult<(Device, AuthTokens)> { + let time_now = Utc::now(); + + let refresh_claims = match decode_refresh(refresh_token) { + Err(err) => err_silent!(format!("Impossible to read refresh_token: {}", err.message())), + Ok(claims) => claims, + }; + + // Get device by refresh token + let mut device = match Device::find_by_refresh_token(&refresh_claims.device_token, conn).await { + None => err!("Invalid refresh token"), + Some(device) => device, + }; + + // Roll the Device.refresh_token this way it invalides old JWT refresh_token + device.roll_refresh_token(); + device.save(conn).await?; + + let user = match User::find_by_uuid(&device.user_uuid, conn).await { + None => err!("Impossible to find user"), + Some(user) => user, + }; + + if refresh_claims.exp < time_now.timestamp() { + err!("Expired refresh token"); + } + + let auth_tokens = match refresh_claims.sub { + AuthMethod::Sso if CONFIG.sso_enabled() && CONFIG.sso_auth_only_not_session() => { + AuthTokens::new(&device, &user, refresh_claims.sub) + } + AuthMethod::Sso if CONFIG.sso_enabled() => sso::exchange_refresh_token(&device, &user, &refresh_claims).await?, + AuthMethod::Sso => err!("SSO is now disabled, Login again using email and master password"), + AuthMethod::Password if CONFIG.sso_enabled() && CONFIG.sso_only() => err!("SSO is now required, Login again"), + AuthMethod::Password => AuthTokens::new(&device, &user, refresh_claims.sub), + _ => err!("Invalid auth method, cannot refresh token"), + }; + + Ok((device, auth_tokens)) +} diff --git a/src/config.rs b/src/config.rs index 20aebd9aa0..10440a49f4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -423,6 +423,9 @@ make_config! { /// Duo Auth context cleanup schedule |> Cron schedule of the job that cleans expired Duo contexts from the database. Does nothing if Duo MFA is disabled or set to use the legacy iframe prompt. /// Defaults to once every minute. Set blank to disable this job. duo_context_purge_schedule: String, false, def, "30 * * * * *".to_string(); + /// Purge incomplete sso nonce. |> Cron schedule of the job that cleans leftover nonce in db due to incomplete sso login. + /// Defaults to daily. Set blank to disable this job. + purge_incomplete_sso_nonce: String, false, def, "0 20 0 * * *".to_string(); }, /// General settings @@ -640,19 +643,35 @@ make_config! { /// OpenID Connect SSO settings sso { /// Enabled - sso_enabled: bool, true, def, false; - /// Force SSO login - sso_only: bool, true, def, false; + sso_enabled: bool, false, def, false; + /// Only sso login |> Disable Email+Master Password login + sso_only: bool, true, def, false; + /// Allow email association |> Associate existing non-sso user based on email + sso_signups_match_email: bool, true, def, true; /// Client ID - sso_client_id: String, true, def, String::new(); + sso_client_id: String, false, def, String::new(); /// Client Key - sso_client_secret: Pass, true, def, String::new(); - /// Authority Server - sso_authority: String, true, def, String::new(); - /// CallBack Path - sso_callback_path: String, false, gen, |c| generate_sso_callback_path(&c.domain); - /// Allow workaround so SSO logins accept all invites - sso_acceptall_invites: bool, true, def, false; + sso_client_secret: Pass, false, def, String::new(); + /// Authority Server |> Base url of the OIDC provider discovery endpoint (without `/.well-known/openid-configuration`) + sso_authority: String, false, def, String::new(); + /// Authorization request scopes |> List the of the needed scope (`openid` is implicit) + sso_scopes: String, false, def, "email profile".to_string(); + /// Authorization request extra parameters + sso_authorize_extra_params: String, false, def, String::new(); + /// Use PKCE during Authorization flow + sso_pkce: bool, false, def, false; + /// Regex for additionnal trusted Id token audience |> By default only the client_id is trsuted. + sso_audience_trusted: String, false, option; + /// CallBack Path |> Generated from Domain. + sso_callback_path: String, false, generated, |c| generate_sso_callback_path(&c.domain); + /// Optional sso master password policy |> Ex format: '{"enforceOnLogin":false,"minComplexity":3,"minLength":12,"requireLower":false,"requireNumbers":false,"requireSpecial":false,"requireUpper":false}' + sso_master_password_policy: String, true, option; + /// Use sso only for auth not the session lifecycle |> Use default Vaultwarden session lifecycle (Idle refresh token valid for 30days) + sso_auth_only_not_session: bool, true, def, false; + /// Client cache for discovery endpoint. |> Duration in seconds (0 or less to disable). More details: https://github.com/dani-garcia/vaultwarden/blob/sso-support/SSO.md#client-cache + sso_client_cache_expiration: u64, true, def, 0; + /// Log all tokens |> `LOG_LEVEL=debug` or `LOG_LEVEL=info,vaultwarden::sso=debug` is required + sso_debug_tokens: bool, true, def, false; }, /// Yubikey settings @@ -873,10 +892,15 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> { err!("All Duo options need to be set for global Duo support") } - if cfg.sso_enabled - && (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") + if cfg.sso_enabled { + if cfg.sso_client_id.is_empty() || cfg.sso_client_secret.is_empty() || cfg.sso_authority.is_empty() { + err!("`SSO_CLIENT_ID`, `SSO_CLIENT_SECRET` and `SSO_AUTHORITY` must be set for SSO support") + } + + internal_sso_issuer_url(&cfg.sso_authority)?; + internal_sso_redirect_url(&cfg.sso_callback_path)?; + check_master_password_policy(&cfg.sso_master_password_policy)?; + internal_sso_authorize_extra_params_vec(&cfg.sso_authorize_extra_params)?; } if cfg._enable_yubico { @@ -1056,6 +1080,35 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> { Ok(()) } +fn internal_sso_issuer_url(sso_authority: &String) -> Result { + match openidconnect::IssuerUrl::new(sso_authority.clone()) { + Err(err) => err!(format!("Invalid sso_authority UR ({sso_authority}): {err}")), + Ok(issuer_url) => Ok(issuer_url), + } +} + +fn internal_sso_redirect_url(sso_callback_path: &String) -> Result { + match openidconnect::RedirectUrl::new(sso_callback_path.clone()) { + Err(err) => err!(format!("Invalid sso_callback_path ({sso_callback_path} built using `domain`) URL: {err}")), + Ok(redirect_url) => Ok(redirect_url), + } +} + +fn internal_sso_authorize_extra_params_vec(config: &str) -> Result, Error> { + match parse_param_list(config.to_owned(), '&', '=') { + Err(e) => err!(format!("Invalid SSO_AUTHORIZE_EXTRA_PARAMS: {e}")), + Ok(params) => Ok(params), + } +} + +fn check_master_password_policy(sso_master_password_policy: &Option) -> Result<(), Error> { + let policy = sso_master_password_policy.as_ref().map(|mpp| serde_json::from_str::(mpp)); + if let Some(Err(error)) = policy { + err!(format!("Invalid sso_master_password_policy ({error}), Ensure that it's correctly escaped with ''")) + } + Ok(()) +} + /// Extracts an RFC 6454 web origin from a URL. fn extract_url_origin(url: &str) -> String { match Url::parse(url) { @@ -1133,6 +1186,26 @@ fn smtp_convert_deprecated_ssl_options(smtp_ssl: Option, smtp_explicit_tls "starttls".to_string() } +/// Allow to parse a list of Key/Values (Ex: `key1=value&key2=value2`) +/// - line break are handled as `separator` +fn parse_param_list(config: String, separator: char, kv_separator: char) -> Result, Error> { + config + .lines() + .flat_map(|l| l.split(separator)) + .map(|l| l.trim()) + .filter(|l| !l.is_empty()) + .map(|l| { + let split = l.split(kv_separator).collect::>(); + match &split[..] { + [key, value] => Ok(((*key).to_string(), (*value).to_string())), + _ => { + err!(format!("Failed to parse ({l}). Expected key{kv_separator}value")) + } + } + }) + .collect() +} + impl Config { pub fn load() -> Result { // Loading from env and file @@ -1325,6 +1398,22 @@ impl Config { } } } + + pub fn sso_issuer_url(&self) -> Result { + internal_sso_issuer_url(&self.sso_authority()) + } + + pub fn sso_redirect_url(&self) -> Result { + internal_sso_redirect_url(&self.sso_callback_path()) + } + + pub fn sso_scopes_vec(&self) -> Vec { + self.sso_scopes().split_whitespace().map(str::to_string).collect() + } + + pub fn sso_authorize_extra_params_vec(&self) -> Result, Error> { + internal_sso_authorize_extra_params_vec(&self.sso_authorize_extra_params()) + } } use handlebars::{ @@ -1387,6 +1476,7 @@ where reg!("email/send_single_org_removed_from_org", ".html"); reg!("email/set_password", ".html"); reg!("email/smtp_test", ".html"); + reg!("email/sso_change_email", ".html"); reg!("email/twofactor_email", ".html"); reg!("email/verify_email", ".html"); reg!("email/welcome_must_verify", ".html"); @@ -1446,3 +1536,54 @@ fn to_json<'reg, 'rc>( out.write(&json)?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_param_list() { + let config = "key1=value&key2=value2&".to_string(); + let parsed = parse_param_list(config, '&', '='); + + assert_eq!( + parsed.unwrap(), + vec![("key1".to_string(), "value".to_string()), ("key2".to_string(), "value2".to_string())] + ); + } + + #[test] + fn test_parse_param_list_lines() { + let config = r#" + key1=value + key2=value2 + "# + .to_string(); + let parsed = parse_param_list(config, '&', '='); + + assert_eq!( + parsed.unwrap(), + vec![("key1".to_string(), "value".to_string()), ("key2".to_string(), "value2".to_string())] + ); + } + + #[test] + fn test_parse_param_list_mixed() { + let config = r#"key1=value&key2=value2& + &key3=value3&& + &key4=value4 + "# + .to_string(); + let parsed = parse_param_list(config, '&', '='); + + assert_eq!( + parsed.unwrap(), + vec![ + ("key1".to_string(), "value".to_string()), + ("key2".to_string(), "value2".to_string()), + ("key3".to_string(), "value3".to_string()), + ("key4".to_string(), "value4".to_string()), + ] + ); + } +} diff --git a/src/db/models/device.rs b/src/db/models/device.rs index 8feab49dd4..867754552f 100644 --- a/src/db/models/device.rs +++ b/src/db/models/device.rs @@ -1,6 +1,7 @@ use chrono::{NaiveDateTime, Utc}; +use data_encoding::{BASE64, BASE64URL}; -use crate::{crypto, CONFIG}; +use crate::crypto; use core::fmt; db_object! { @@ -42,13 +43,16 @@ impl Device { push_uuid: None, push_token: None, - refresh_token: String::new(), + refresh_token: crypto::encode_random_bytes::<64>(BASE64URL), twofactor_remember: None, } } + pub fn roll_refresh_token(&mut self) { + self.refresh_token = crypto::encode_random_bytes::<64>(BASE64URL) + } + pub fn refresh_twofactor_remember(&mut self) -> String { - use data_encoding::BASE64; let twofactor_remember = crypto::encode_random_bytes::<180>(BASE64); self.twofactor_remember = Some(twofactor_remember.clone()); @@ -59,61 +63,6 @@ impl Device { self.twofactor_remember = None; } - pub fn refresh_tokens(&mut self, user: &super::User, scope: Vec) -> (String, i64) { - // If there is no refresh token, we create one - if self.refresh_token.is_empty() { - use data_encoding::BASE64URL; - self.refresh_token = crypto::encode_random_bytes::<64>(BASE64URL); - } - - // Update the expiration of the device and the last update date - let time_now = Utc::now(); - self.updated_at = time_now.naive_utc(); - - // --- - // Disabled these keys to be added to the JWT since they could cause the JWT to get too large - // Also These key/value pairs are not used anywhere by either Vaultwarden or Bitwarden Clients - // Because these might get used in the future, and they are added by the Bitwarden Server, lets keep it, but then commented out - // --- - // fn arg: orgs: Vec, - // --- - // let orgowner: Vec<_> = orgs.iter().filter(|o| o.atype == 0).map(|o| o.org_uuid.clone()).collect(); - // let orgadmin: Vec<_> = orgs.iter().filter(|o| o.atype == 1).map(|o| o.org_uuid.clone()).collect(); - // let orguser: Vec<_> = orgs.iter().filter(|o| o.atype == 2).map(|o| o.org_uuid.clone()).collect(); - // let orgmanager: Vec<_> = orgs.iter().filter(|o| o.atype == 3).map(|o| o.org_uuid.clone()).collect(); - - // Create the JWT claims struct, to send to the client - use crate::auth::{encode_jwt, LoginJwtClaims, DEFAULT_VALIDITY, JWT_LOGIN_ISSUER}; - let claims = LoginJwtClaims { - nbf: time_now.timestamp(), - exp: (time_now + *DEFAULT_VALIDITY).timestamp(), - iss: JWT_LOGIN_ISSUER.to_string(), - sub: user.uuid.clone(), - - premium: true, - name: user.name.clone(), - email: user.email.clone(), - email_verified: !CONFIG.mail_enabled() || user.verified_at.is_some(), - - // --- - // Disabled these keys to be added to the JWT since they could cause the JWT to get too large - // Also These key/value pairs are not used anywhere by either Vaultwarden or Bitwarden Clients - // Because these might get used in the future, and they are added by the Bitwarden Server, lets keep it, but then commented out - // See: https://github.com/dani-garcia/vaultwarden/issues/4156 - // --- - // orgowner, - // orgadmin, - // orguser, - // orgmanager, - sstamp: user.security_stamp.clone(), - device: self.uuid.clone(), - scope, - amr: vec!["Application".into()], - }; - - (encode_jwt(&claims), DEFAULT_VALIDITY.num_seconds()) - } - pub fn is_push_device(&self) -> bool { matches!(DeviceType::from_i32(self.atype), DeviceType::Android | DeviceType::Ios) } diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index 72afcd750f..83e40763d4 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -34,4 +34,4 @@ pub use self::sso_nonce::SsoNonce; pub use self::two_factor::{TwoFactor, TwoFactorType}; pub use self::two_factor_duo_context::TwoFactorDuoContext; pub use self::two_factor_incomplete::TwoFactorIncomplete; -pub use self::user::{Invitation, User, UserKdfType, UserStampException}; +pub use self::user::{Invitation, SsoUser, User, UserKdfType, UserStampException}; diff --git a/src/db/models/org_policy.rs b/src/db/models/org_policy.rs index 935e4bc38b..efba120f9c 100644 --- a/src/db/models/org_policy.rs +++ b/src/db/models/org_policy.rs @@ -27,7 +27,7 @@ pub enum OrgPolicyType { MasterPassword = 1, PasswordGenerator = 2, SingleOrg = 3, - RequireSso = 4, + // RequireSso = 4, // Not supported PersonalOwnership = 5, DisableSend = 6, SendOptions = 7, @@ -77,12 +77,11 @@ impl OrgPolicy { } pub fn to_json(&self) -> Value { - let data_json: Value = serde_json::from_str(&self.data).unwrap_or(Value::Null); json!({ "id": self.uuid, "organizationId": self.org_uuid, "type": self.atype, - "data": data_json, + "data": serde_json::from_str(&self.data).unwrap_or(Value::Null), "enabled": self.enabled, "object": "policy", }) diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index c42050ff9f..3cfc44daab 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -29,6 +29,7 @@ db_object! { pub uuid: String, pub user_uuid: String, pub org_uuid: String, + pub invited_by_email: Option, pub access_all: bool, pub akey: String, @@ -170,9 +171,9 @@ impl Organization { "useGroups": CONFIG.org_groups_enabled(), "useTotp": true, "usePolicies": true, - // "UseScim": false, // Not supported (Not AGPLv3 Licensed) - "useSso": CONFIG.sso_enabled(), - // "UseKeyConnector": false, // Not supported + // "useScim": false, // Not supported (Not AGPLv3 Licensed) + "useSso": false, // Not supported + // "useKeyConnector": false, // Not supported "selfHost": true, "useApi": true, "hasPublicAndPrivateKeys": self.private_key.is_some() && self.public_key.is_some(), @@ -200,12 +201,13 @@ impl Organization { static ACTIVATE_REVOKE_DIFF: i32 = 128; impl UserOrganization { - pub fn new(user_uuid: String, org_uuid: String) -> Self { + pub fn new(user_uuid: String, org_uuid: String, invited_by_email: Option) -> Self { Self { uuid: crate::util::get_uuid(), user_uuid, org_uuid, + invited_by_email, access_all: false, akey: String::new(), @@ -396,7 +398,7 @@ impl UserOrganization { "resetPasswordEnrolled": self.reset_password_key.is_some(), "useResetPassword": CONFIG.mail_enabled(), "ssoBound": false, // Not supported - "useSso": CONFIG.sso_enabled(), + "useSso": false, // Not supported "useKeyConnector": false, "useSecretsManager": false, "usePasswordManager": true, @@ -730,6 +732,17 @@ impl UserOrganization { }} } + pub async fn confirm_user_invitations(user_uuid: &str, 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(UserOrgStatus::Invited as i32)) + .set(users_organizations::status.eq(UserOrgStatus::Accepted as i32)) + .execute(conn) + .map_res("Error confirming invitations") + }} + } + pub async fn find_any_state_by_user(user_uuid: &str, conn: &mut DbConn) -> Vec { db_run! { conn: { users_organizations::table diff --git a/src/db/models/sso_nonce.rs b/src/db/models/sso_nonce.rs index 0a9533e032..881f075bfe 100644 --- a/src/db/models/sso_nonce.rs +++ b/src/db/models/sso_nonce.rs @@ -1,21 +1,34 @@ +use chrono::{NaiveDateTime, Utc}; + use crate::api::EmptyResult; -use crate::db::DbConn; +use crate::db::{DbConn, DbPool}; use crate::error::MapResult; +use crate::sso::NONCE_EXPIRATION; db_object! { #[derive(Identifiable, Queryable, Insertable)] #[diesel(table_name = sso_nonce)] - #[diesel(primary_key(nonce))] + #[diesel(primary_key(state))] pub struct SsoNonce { + pub state: String, pub nonce: String, + pub verifier: Option, + pub redirect_uri: String, + pub created_at: NaiveDateTime, } } /// Local methods impl SsoNonce { - pub fn new(nonce: String) -> Self { - Self { + pub fn new(state: String, nonce: String, verifier: Option, redirect_uri: String) -> Self { + let now = Utc::now().naive_utc(); + + SsoNonce { + state, nonce, + verifier, + redirect_uri, + created_at: now, } } } @@ -28,7 +41,7 @@ impl SsoNonce { diesel::replace_into(sso_nonce::table) .values(SsoNonceDb::to_db(self)) .execute(conn) - .map_res("Error saving SSO device") + .map_res("Error saving SSO nonce") } postgresql { let value = SsoNonceDb::to_db(self); @@ -40,21 +53,37 @@ impl SsoNonce { } } - pub async fn delete(self, conn: &mut DbConn) -> EmptyResult { + pub async fn delete(state: &str, conn: &mut DbConn) -> EmptyResult { db_run! { conn: { - diesel::delete(sso_nonce::table.filter(sso_nonce::nonce.eq(self.nonce))) + diesel::delete(sso_nonce::table.filter(sso_nonce::state.eq(state))) .execute(conn) .map_res("Error deleting SSO nonce") }} } - pub async fn find(nonce: &str, conn: &mut DbConn) -> Option { + pub async fn find(state: &str, conn: &DbConn) -> Option { + let oldest = Utc::now().naive_utc() - *NONCE_EXPIRATION; db_run! { conn: { sso_nonce::table - .filter(sso_nonce::nonce.eq(nonce)) + .filter(sso_nonce::state.eq(state)) + .filter(sso_nonce::created_at.ge(oldest)) .first::(conn) .ok() .from_db() }} } + + pub async fn delete_expired(pool: DbPool) -> EmptyResult { + debug!("Purging expired sso_nonce"); + if let Ok(conn) = pool.get().await { + let oldest = Utc::now().naive_utc() - *NONCE_EXPIRATION; + db_run! { conn: { + diesel::delete(sso_nonce::table.filter(sso_nonce::created_at.lt(oldest))) + .execute(conn) + .map_res("Error deleting expired SSO nonce") + }} + } else { + err!("Failed to get DB connection while purging expired sso_nonce") + } + } } diff --git a/src/db/models/user.rs b/src/db/models/user.rs index 94f42c8420..c553fd0e6f 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -6,7 +6,7 @@ use crate::crypto; use crate::CONFIG; db_object! { - #[derive(Identifiable, Queryable, Insertable, AsChangeset)] + #[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)] #[diesel(table_name = users)] #[diesel(treat_none_as_null = true)] #[diesel(primary_key(uuid))] @@ -61,6 +61,14 @@ db_object! { pub struct Invitation { pub email: String, } + + #[derive(Identifiable, Queryable, Insertable, Selectable)] + #[diesel(table_name = sso_users)] + #[diesel(primary_key(user_uuid))] + pub struct SsoUser { + pub user_uuid: String, + pub identifier: String, + } } pub enum UserKdfType { @@ -86,7 +94,7 @@ impl User { pub const CLIENT_KDF_TYPE_DEFAULT: i32 = UserKdfType::Pbkdf2 as i32; pub const CLIENT_KDF_ITER_DEFAULT: i32 = 600_000; - pub fn new(email: String) -> Self { + pub fn new(email: String, name: Option) -> Self { let now = Utc::now().naive_utc(); let email = email.to_lowercase(); @@ -98,7 +106,7 @@ impl User { verified_at: None, last_verifying_at: None, login_verify_count: 0, - name: email.clone(), + name: name.unwrap_or(email.clone()), email, akey: String::new(), email_new: None, @@ -458,3 +466,51 @@ impl Invitation { } } } + +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") + } + } + } + + // Written as an union to make the query more lisible than using an `or_filter`. + // But `first()` does not appear to work with `union()` so we use `load()`. + pub async fn find_by_identifier_or_email( + identifier: &str, + mail: &str, + conn: &DbConn, + ) -> Option<(User, Option)> { + let lower_mail = mail.to_lowercase(); + + db_run! {conn: { + users::table + .inner_join(sso_users::table) + .select(<(UserDb, Option)>::as_select()) + .filter(sso_users::identifier.eq(identifier)) + .union( + users::table + .left_join(sso_users::table) + .select(<(UserDb, Option)>::as_select()) + .filter(users::email.eq(lower_mail)) + ) + .load(conn) + .expect("Error searching user by SSO identifier and email") + .into_iter() + .next() + .map(|(user, sso_user)| { (user.from_db(), sso_user.from_db()) }) + }} + } +} diff --git a/src/db/schemas/mysql/schema.rs b/src/db/schemas/mysql/schema.rs index 70cc596064..89c4a4d1dd 100644 --- a/src/db/schemas/mysql/schema.rs +++ b/src/db/schemas/mysql/schema.rs @@ -234,6 +234,7 @@ table! { uuid -> Text, user_uuid -> Text, org_uuid -> Text, + invited_by_email -> Nullable, access_all -> Bool, akey -> Text, status -> Integer, @@ -254,8 +255,19 @@ table! { } table! { - sso_nonce (nonce) { + sso_nonce (state) { + state -> Text, nonce -> Text, + verifier -> Nullable, + redirect_uri -> Text, + created_at -> Timestamp, + } +} + +table! { + sso_users (user_uuid) { + user_uuid -> Text, + identifier -> Text, } } @@ -352,6 +364,7 @@ joinable!(collections_groups -> collections (collections_uuid)); joinable!(collections_groups -> groups (groups_uuid)); joinable!(event -> users_organizations (uuid)); joinable!(auth_requests -> users (user_uuid)); +joinable!(sso_users -> users (user_uuid)); allow_tables_to_appear_in_same_query!( attachments, @@ -365,6 +378,7 @@ allow_tables_to_appear_in_same_query!( org_policies, organizations, sends, + sso_users, twofactor, users, users_collections, diff --git a/src/db/schemas/postgresql/schema.rs b/src/db/schemas/postgresql/schema.rs index 1761858d41..517fe0c21d 100644 --- a/src/db/schemas/postgresql/schema.rs +++ b/src/db/schemas/postgresql/schema.rs @@ -234,6 +234,7 @@ table! { uuid -> Text, user_uuid -> Text, org_uuid -> Text, + invited_by_email -> Nullable, access_all -> Bool, akey -> Text, status -> Integer, @@ -254,8 +255,19 @@ table! { } table! { - sso_nonce (nonce) { + sso_nonce (state) { + state -> Text, nonce -> Text, + verifier -> Nullable, + redirect_uri -> Text, + created_at -> Timestamp, + } +} + +table! { + sso_users (user_uuid) { + user_uuid -> Text, + identifier -> Text, } } @@ -352,6 +364,7 @@ joinable!(collections_groups -> collections (collections_uuid)); joinable!(collections_groups -> groups (groups_uuid)); joinable!(event -> users_organizations (uuid)); joinable!(auth_requests -> users (user_uuid)); +joinable!(sso_users -> users (user_uuid)); allow_tables_to_appear_in_same_query!( attachments, @@ -365,6 +378,7 @@ allow_tables_to_appear_in_same_query!( org_policies, organizations, sends, + sso_users, twofactor, users, users_collections, diff --git a/src/db/schemas/sqlite/schema.rs b/src/db/schemas/sqlite/schema.rs index 1761858d41..517fe0c21d 100644 --- a/src/db/schemas/sqlite/schema.rs +++ b/src/db/schemas/sqlite/schema.rs @@ -234,6 +234,7 @@ table! { uuid -> Text, user_uuid -> Text, org_uuid -> Text, + invited_by_email -> Nullable, access_all -> Bool, akey -> Text, status -> Integer, @@ -254,8 +255,19 @@ table! { } table! { - sso_nonce (nonce) { + sso_nonce (state) { + state -> Text, nonce -> Text, + verifier -> Nullable, + redirect_uri -> Text, + created_at -> Timestamp, + } +} + +table! { + sso_users (user_uuid) { + user_uuid -> Text, + identifier -> Text, } } @@ -352,6 +364,7 @@ joinable!(collections_groups -> collections (collections_uuid)); joinable!(collections_groups -> groups (groups_uuid)); joinable!(event -> users_organizations (uuid)); joinable!(auth_requests -> users (user_uuid)); +joinable!(sso_users -> users (user_uuid)); allow_tables_to_appear_in_same_query!( attachments, @@ -365,6 +378,7 @@ allow_tables_to_appear_in_same_query!( org_policies, organizations, sends, + sso_users, twofactor, users, users_collections, diff --git a/src/error.rs b/src/error.rs index 1061a08dc6..bf9f2cf405 100644 --- a/src/error.rs +++ b/src/error.rs @@ -147,6 +147,10 @@ impl Error { pub fn get_event(&self) -> &Option { &self.event } + + pub fn message(&self) -> &str { + &self.message + } } pub trait MapResult { diff --git a/src/mail.rs b/src/mail.rs index 3850ae1fc0..97face07f9 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -281,7 +281,11 @@ pub async fn send_invite( .append_pair("organizationId", org_id.as_deref().unwrap_or("_")) .append_pair("organizationUserId", org_user_id.as_deref().unwrap_or("_")) .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"); } } @@ -552,6 +556,18 @@ pub async fn send_change_email(address: &str, token: &str) -> EmptyResult { send_email(address, &subject, body_html, body_text).await } +pub async fn send_sso_change_email(address: &str) -> EmptyResult { + let (subject, body_html, body_text) = get_text( + "email/sso_change_email", + json!({ + "url": format!("{}/#/settings/account", CONFIG.domain()), + "img_src": CONFIG._smtp_img_src(), + }), + )?; + + send_email(address, &subject, body_html, body_text).await +} + pub async fn send_set_password(address: &str, user_name: &str) -> EmptyResult { let (subject, body_html, body_text) = get_text( "email/set_password", diff --git a/src/main.rs b/src/main.rs index 7e180e2e20..ca00c499fc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -54,6 +54,7 @@ mod db; mod http_client; mod mail; mod ratelimit; +mod sso; mod util; use crate::api::core::two_factor::duo_oidc::purge_duo_contexts; @@ -702,6 +703,13 @@ fn schedule_jobs(pool: db::DbPool) { })); } + // Purge sso nonce from incomplete flow (default to daily at 00h20). + if !CONFIG.purge_incomplete_sso_nonce().is_empty() { + sched.add(Job::new(CONFIG.purge_incomplete_sso_nonce().parse().unwrap(), || { + runtime.spawn(db::models::SsoNonce::delete_expired(pool.clone())); + })); + } + // Periodically check for jobs to run. We probably won't need any // jobs that run more often than once a minute, so a default poll // interval of 30 seconds should be sufficient. Users who want to diff --git a/src/sso.rs b/src/sso.rs new file mode 100644 index 0000000000..ad57fbbce9 --- /dev/null +++ b/src/sso.rs @@ -0,0 +1,544 @@ +use chrono::Utc; +use regex::Regex; +use std::borrow::Cow; +use std::time::Duration; +use url::Url; + +use mini_moka::sync::Cache; +use once_cell::sync::Lazy; +use openidconnect::core::{ + CoreClient, CoreIdTokenVerifier, CoreProviderMetadata, CoreResponseType, CoreUserInfoClaims, +}; +use openidconnect::reqwest::async_http_client; +use openidconnect::{ + AccessToken, AuthDisplay, AuthPrompt, AuthenticationFlow, AuthorizationCode, AuthorizationRequest, ClientId, + ClientSecret, CsrfToken, Nonce, OAuth2TokenResponse, PkceCodeChallenge, PkceCodeVerifier, RefreshToken, + ResponseType, Scope, +}; + +use crate::{ + api::ApiResult, + auth, + auth::{AuthMethod, AuthMethodScope, AuthTokens, TokenWrapper, BW_EXPIRATION, DEFAULT_REFRESH_VALIDITY}, + db::{ + models::{Device, SsoNonce, User}, + DbConn, + }, + CONFIG, +}; + +static AC_CACHE: Lazy> = + Lazy::new(|| Cache::builder().max_capacity(1000).time_to_live(Duration::from_secs(10 * 60)).build()); + +static CLIENT_CACHE_KEY: Lazy = Lazy::new(|| "sso-client".to_string()); +static CLIENT_CACHE: Lazy> = Lazy::new(|| { + Cache::builder().max_capacity(1).time_to_live(Duration::from_secs(CONFIG.sso_client_cache_expiration())).build() +}); + +static SSO_JWT_ISSUER: Lazy = Lazy::new(|| format!("{}|sso", CONFIG.domain_origin())); + +pub static NONCE_EXPIRATION: Lazy = Lazy::new(|| chrono::TimeDelta::try_minutes(10).unwrap()); + +trait AuthorizationRequestExt<'a> { + fn add_extra_params>, V: Into>>(self, params: Vec<(N, V)>) -> Self; +} + +impl<'a, AD: AuthDisplay, P: AuthPrompt, RT: ResponseType> AuthorizationRequestExt<'a> + for AuthorizationRequest<'a, AD, P, RT> +{ + fn add_extra_params>, V: Into>>(mut self, params: Vec<(N, V)>) -> Self { + for (key, value) in params { + self = self.add_extra_param(key, value); + } + self + } +} + +#[derive(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 { + code: String, + state: String, + }, + Error { + state: String, + error: String, + error_description: Option, + }, +} + +#[derive(Debug, Serialize, Deserialize)] +struct OIDCCodeClaims { + // Expiration time + pub exp: i64, + // Issuer + pub iss: String, + + pub code: OIDCCodeWrapper, +} + +pub fn encode_code_claims(code: OIDCCodeWrapper) -> String { + let time_now = Utc::now(); + let claims = OIDCCodeClaims { + exp: (time_now + chrono::TimeDelta::try_minutes(5).unwrap()).timestamp(), + iss: SSO_JWT_ISSUER.to_string(), + code, + }; + + auth::encode_jwt(&claims) +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct BasicTokenClaims { + iat: Option, + nbf: Option, + exp: i64, +} + +impl BasicTokenClaims { + fn nbf(&self) -> i64 { + self.nbf.or(self.iat).unwrap_or_else(|| Utc::now().timestamp()) + } +} + +fn decode_token_claims(token_name: &str, token: &str) -> ApiResult { + let mut validation = jsonwebtoken::Validation::default(); + validation.set_issuer(&[CONFIG.sso_authority()]); + validation.insecure_disable_signature_validation(); + validation.validate_aud = false; + + match jsonwebtoken::decode(token, &jsonwebtoken::DecodingKey::from_secret(&[]), &validation) { + Ok(btc) => Ok(btc.claims), + Err(err) => err_silent!(format!("Failed to decode basic token claims from {token_name}: {err}")), + } +} + +#[rocket::async_trait] +trait CoreClientExt { + async fn _get_client() -> ApiResult; + async fn cached() -> ApiResult; + + async fn user_info_async(&self, access_token: AccessToken) -> ApiResult; + + fn vw_id_token_verifier(&self) -> CoreIdTokenVerifier<'_>; +} + +#[rocket::async_trait] +impl CoreClientExt for CoreClient { + // Call the OpenId discovery endpoint to retrieve configuration + async fn _get_client() -> ApiResult { + let client_id = ClientId::new(CONFIG.sso_client_id()); + let client_secret = ClientSecret::new(CONFIG.sso_client_secret()); + + let issuer_url = CONFIG.sso_issuer_url()?; + + let provider_metadata = match CoreProviderMetadata::discover_async(issuer_url, async_http_client).await { + Err(err) => err!(format!("Failed to discover OpenID provider: {err}")), + Ok(metadata) => metadata, + }; + + Ok(CoreClient::from_provider_metadata(provider_metadata, client_id, Some(client_secret)) + .set_redirect_uri(CONFIG.sso_redirect_url()?)) + } + + // Simple cache to prevent recalling the discovery endpoint each time + async fn cached() -> ApiResult { + if CONFIG.sso_client_cache_expiration() > 0 { + match CLIENT_CACHE.get(&*CLIENT_CACHE_KEY) { + Some(client) => Ok(client), + None => Self::_get_client().await.inspect(|client| { + debug!("Inserting new client in cache"); + CLIENT_CACHE.insert(CLIENT_CACHE_KEY.clone(), client.clone()); + }), + } + } else { + Self::_get_client().await + } + } + + async fn user_info_async(&self, access_token: AccessToken) -> ApiResult { + let endpoint = match self.user_info(access_token, None) { + Err(err) => err!(format!("No user_info endpoint: {err}")), + Ok(endpoint) => endpoint, + }; + + match endpoint.request_async(async_http_client).await { + Err(err) => err!(format!("Request to user_info endpoint failed: {err}")), + Ok(user_info) => Ok(user_info), + } + } + + fn vw_id_token_verifier(&self) -> CoreIdTokenVerifier<'_> { + let mut verifier = self.id_token_verifier(); + if let Some(regex_str) = CONFIG.sso_audience_trusted() { + match Regex::new(®ex_str) { + Ok(regex) => { + verifier = verifier.set_other_audience_verifier_fn(move |aud| regex.is_match(aud)); + } + Err(err) => { + error!("Failed to parse SSO_AUDIENCE_TRUSTED={regex_str} regex: {err}"); + } + } + } + verifier + } +} + +// 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: String, client_id: &str, raw_redirect_uri: &str, mut conn: DbConn) -> ApiResult { + let scopes = CONFIG.sso_scopes_vec().into_iter().map(Scope::new); + + let redirect_uri = match client_id { + "web" | "browser" => format!("{}/sso-connector.html", CONFIG.domain()), + "desktop" | "mobile" => "bitwarden://sso-callback".to_string(), + "cli" => { + let port_regex = Regex::new(r"^http://localhost:([0-9]{4})$").unwrap(); + match port_regex.captures(raw_redirect_uri).and_then(|captures| captures.get(1).map(|c| c.as_str())) { + Some(port) => format!("http://localhost:{}", port), + None => err!("Failed to extract port number"), + } + } + _ => err!(format!("Unsupported client {client_id}")), + }; + + let client = CoreClient::cached().await?; + let mut auth_req = client + .authorize_url( + AuthenticationFlow::::AuthorizationCode, + || CsrfToken::new(state), + Nonce::new_random, + ) + .add_scopes(scopes) + .add_extra_params(CONFIG.sso_authorize_extra_params_vec()?); + + let verifier = if CONFIG.sso_pkce() { + let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); + auth_req = auth_req.set_pkce_challenge(pkce_challenge); + Some(pkce_verifier.secret().to_string()) + } else { + None + }; + + let (auth_url, csrf_state, nonce) = auth_req.url(); + + let sso_nonce = SsoNonce::new(csrf_state.secret().to_string(), nonce.secret().to_string(), verifier, redirect_uri); + sso_nonce.save(&mut conn).await?; + + Ok(auth_url) +} + +#[derive(Clone, Debug)] +pub struct AuthenticatedUser { + pub refresh_token: Option, + pub access_token: String, + pub expires_in: Option, + pub identifier: String, + pub email: String, + pub email_verified: Option, + pub user_name: Option, +} + +#[derive(Clone, Debug)] +pub struct UserInformation { + pub state: String, + pub identifier: String, + pub email: String, + pub email_verified: Option, + pub user_name: Option, +} + +async fn decode_code_claims(code: &str, conn: &mut DbConn) -> ApiResult<(String, String)> { + match auth::decode_jwt::(code, SSO_JWT_ISSUER.to_string()) { + Ok(code_claims) => match code_claims.code { + OIDCCodeWrapper::Ok { + code, + state, + } => Ok((code, state)), + OIDCCodeWrapper::Error { + state, + error, + error_description, + } => { + if let Err(err) = SsoNonce::delete(&state, conn).await { + error!("Failed to delete database sso_nonce using {state}: {err}") + } + err!(format!( + "SSO authorization failed: {error}, {}", + error_description.as_ref().unwrap_or(&String::new()) + )) + } + }, + Err(err) => err!(format!("Failed to decode code wrapper: {err}")), + } +} + +// During the 2FA flow we will +// - retrieve the user information and then only discover he needs 2FA. +// - second time we will rely on the `AC_CACHE` since the `code` has already been exchanged. +// The `nonce` will ensure that the user is authorized only once. +// We return only the `UserInformation` to force calling `redeem` to obtain the `refresh_token`. +pub async fn exchange_code(wrapped_code: &str, conn: &mut DbConn) -> ApiResult { + let (code, state) = decode_code_claims(wrapped_code, conn).await?; + + if let Some(authenticated_user) = AC_CACHE.get(&state) { + return Ok(UserInformation { + state, + identifier: authenticated_user.identifier, + email: authenticated_user.email, + email_verified: authenticated_user.email_verified, + user_name: authenticated_user.user_name, + }); + } + + let oidc_code = AuthorizationCode::new(code.clone()); + let client = CoreClient::cached().await?; + + let nonce = match SsoNonce::find(&state, conn).await { + None => err!(format!("Invalid state cannot retrieve nonce")), + Some(nonce) => nonce, + }; + + let mut exchange = client.exchange_code(oidc_code); + + if CONFIG.sso_pkce() { + match nonce.verifier { + None => err!(format!("Missing verifier in the DB nonce table")), + Some(secret) => exchange = exchange.set_pkce_verifier(PkceCodeVerifier::new(secret)), + } + } + + match exchange.request_async(async_http_client).await { + Ok(token_response) => { + let user_info = client.user_info_async(token_response.access_token().to_owned()).await?; + let oidc_nonce = Nonce::new(nonce.nonce.clone()); + + let id_token = match token_response.extra_fields().id_token() { + None => err!("Token response did not contain an id_token"), + Some(token) => token, + }; + + if CONFIG.sso_debug_tokens() { + debug!("Id token: {}", id_token.to_string()); + debug!("Access token: {}", token_response.access_token().secret().to_string()); + debug!("Refresh token: {:?}", token_response.refresh_token().map(|t| t.secret().to_string())); + debug!("Expiration time: {:?}", token_response.expires_in()); + } + + let id_claims = match id_token.claims(&client.vw_id_token_verifier(), &oidc_nonce) { + Ok(claims) => claims, + Err(err) => { + if CONFIG.sso_client_cache_expiration() > 0 { + CLIENT_CACHE.invalidate(&*CLIENT_CACHE_KEY); + } + err!(format!("Could not read id_token claims, {err}")); + } + }; + + let email = match id_claims.email() { + Some(email) => email.to_string(), + None => match user_info.email() { + None => err!("Neither id token nor userinfo contained an email"), + Some(email) => email.to_owned().to_string(), + }, + } + .to_lowercase(); + + let user_name = user_info.preferred_username().map(|un| un.to_string()); + + let refresh_token = token_response.refresh_token().map(|t| t.secret().to_string()); + if refresh_token.is_none() && CONFIG.sso_scopes_vec().contains(&"offline_access".to_string()) { + error!("Scope offline_access is present but response contain no refresh_token"); + } + + let identifier = format!("{}/{}", **id_claims.issuer(), **id_claims.subject()); + + let authenticated_user = AuthenticatedUser { + refresh_token, + access_token: token_response.access_token().secret().to_string(), + expires_in: token_response.expires_in(), + identifier: identifier.clone(), + email: email.clone(), + email_verified: id_claims.email_verified(), + user_name: user_name.clone(), + }; + + AC_CACHE.insert(state.clone(), authenticated_user.clone()); + + Ok(UserInformation { + state, + identifier, + email, + email_verified: id_claims.email_verified(), + user_name, + }) + } + Err(err) => err!(format!("Failed to contact token endpoint: {err}")), + } +} + +// User has passed 2FA flow we can delete `nonce` and clear the cache. +pub async fn redeem(state: &String, conn: &mut DbConn) -> ApiResult { + if let Err(err) = SsoNonce::delete(state, conn).await { + error!("Failed to delete database sso_nonce using {state}: {err}") + } + + if let Some(au) = AC_CACHE.get(state) { + AC_CACHE.invalidate(state); + Ok(au) + } else { + err!("Failed to retrieve user info from sso cache") + } +} + +// We always return a refresh_token (with no refresh_token some secrets are not displayed in the web front). +// If there is no SSO refresh_token, we keep the access_token to be able to call user_info to check for validity +pub fn create_auth_tokens( + device: &Device, + user: &User, + refresh_token: Option, + access_token: &str, + expires_in: Option, +) -> ApiResult { + if !CONFIG.sso_auth_only_not_session() { + let now = Utc::now(); + + let (ap_nbf, ap_exp) = match (decode_token_claims("access_token", access_token), expires_in) { + (Ok(ap), _) => (ap.nbf(), ap.exp), + (Err(_), Some(exp)) => (now.timestamp(), (now + exp).timestamp()), + _ => err!("Non jwt access_token and empty expires_in"), + }; + + let access_claims = auth::LoginJwtClaims::new(device, user, ap_nbf, ap_exp, AuthMethod::Sso.scope_vec(), now); + + _create_auth_tokens(device, refresh_token, access_claims, access_token) + } else { + Ok(AuthTokens::new(device, user, AuthMethod::Sso)) + } +} + +fn _create_auth_tokens( + device: &Device, + refresh_token: Option, + access_claims: auth::LoginJwtClaims, + access_token: &str, +) -> ApiResult { + let (nbf, exp, token) = if let Some(rt) = refresh_token.as_ref() { + match decode_token_claims("refresh_token", rt) { + Err(_) => { + let time_now = Utc::now(); + let exp = (time_now + *DEFAULT_REFRESH_VALIDITY).timestamp(); + debug!("Non jwt refresh_token (expiration set to {})", exp); + (time_now.timestamp(), exp, TokenWrapper::Refresh(rt.to_string())) + } + Ok(refresh_payload) => { + debug!("Refresh_payload: {:?}", refresh_payload); + (refresh_payload.nbf(), refresh_payload.exp, TokenWrapper::Refresh(rt.to_string())) + } + } + } else { + debug!("No refresh_token present"); + (access_claims.nbf, access_claims.exp, TokenWrapper::Access(access_token.to_string())) + }; + + let refresh_claims = auth::RefreshJwtClaims { + nbf, + exp, + iss: auth::JWT_LOGIN_ISSUER.to_string(), + sub: AuthMethod::Sso, + device_token: device.refresh_token.clone(), + token: Some(token), + }; + + Ok(AuthTokens { + refresh_claims, + access_claims, + }) +} + +// This endpoint is called in two case +// - the session is close to expiration we will try to extend it +// - the user is going to make an action and we check that the session is still valid +pub async fn exchange_refresh_token( + device: &Device, + user: &User, + refresh_claims: &auth::RefreshJwtClaims, +) -> ApiResult { + match &refresh_claims.token { + Some(TokenWrapper::Refresh(refresh_token)) => { + let rt = RefreshToken::new(refresh_token.to_string()); + + let client = CoreClient::cached().await?; + + let token_response = match client.exchange_refresh_token(&rt).request_async(async_http_client).await { + Err(err) => err!(format!("Request to exchange_refresh_token endpoint failed: {:?}", err)), + Ok(token_response) => token_response, + }; + + // Use new refresh_token if returned + let rolled_refresh_token = token_response + .refresh_token() + .map(|token| token.secret().to_string()) + .unwrap_or(refresh_token.to_string()); + + create_auth_tokens( + device, + user, + Some(rolled_refresh_token), + token_response.access_token().secret(), + token_response.expires_in(), + ) + } + Some(TokenWrapper::Access(access_token)) => { + let now = Utc::now(); + let exp_limit = (now + *BW_EXPIRATION).timestamp(); + + if refresh_claims.exp < exp_limit { + err_silent!("Access token is close to expiration but we have no refresh token") + } + + let client = CoreClient::cached().await?; + match client.user_info_async(AccessToken::new(access_token.to_string())).await { + Err(err) => { + err_silent!(format!("Failed to retrieve user info, token has probably been invalidated: {err}")) + } + Ok(_) => { + let access_claims = auth::LoginJwtClaims::new( + device, + user, + now.timestamp(), + refresh_claims.exp, + AuthMethod::Sso.scope_vec(), + now, + ); + _create_auth_tokens(device, None, access_claims, access_token) + } + } + } + None => err!("No token present while in SSO"), + } +} diff --git a/src/static/templates/email/sso_change_email.hbs b/src/static/templates/email/sso_change_email.hbs new file mode 100644 index 0000000000..5a512280c9 --- /dev/null +++ b/src/static/templates/email/sso_change_email.hbs @@ -0,0 +1,4 @@ +Your Email Changed + +Your email was changed in your SSO Provider. Please update your email in Account Settings ({{url}}). +{{> email/email_footer_text }} diff --git a/src/static/templates/email/sso_change_email.html.hbs b/src/static/templates/email/sso_change_email.html.hbs new file mode 100644 index 0000000000..74cd445cd6 --- /dev/null +++ b/src/static/templates/email/sso_change_email.html.hbs @@ -0,0 +1,11 @@ +Your Email Changed + +{{> email/email_header }} + + + + +
+ Your email was changed in your SSO Provider. Please update your email in Account Settings. +
+{{> email/email_footer }} diff --git a/src/util.rs b/src/util.rs index 1b77040bec..400e86a9b8 100644 --- a/src/util.rs +++ b/src/util.rs @@ -6,7 +6,7 @@ use std::{collections::HashMap, io::Cursor, ops::Deref, path::Path}; use num_traits::ToPrimitive; use rocket::{ fairing::{Fairing, Info, Kind}, - http::{ContentType, Cookie, CookieJar, Header, HeaderMap, Method, SameSite, Status}, + http::{ContentType, Header, HeaderMap, Method, Status}, request::FromParam, response::{self, Responder}, Data, Orbit, Request, Response, Rocket, @@ -129,10 +129,12 @@ impl Cors { // If a match exists, return it. Otherwise, return None. fn get_allowed_origin(headers: &HeaderMap<'_>) -> Option { let origin = Cors::get_header(headers, "Origin"); - let domain_origin = CONFIG.domain_origin(); - let sso_origin = CONFIG.sso_authority(); let safari_extension_origin = "file://"; - if origin == domain_origin || origin == safari_extension_origin || origin == sso_origin { + + if origin == CONFIG.domain_origin() + || origin == safari_extension_origin + || (CONFIG.sso_enabled() && origin == CONFIG.sso_authority()) + { Some(origin) } else { None @@ -257,33 +259,6 @@ impl<'r> FromParam<'r> for SafeString { } } -pub struct CustomRedirect { - pub url: String, - pub headers: Vec<(String, String)>, -} - -impl<'r> rocket::response::Responder<'r, 'static> for CustomRedirect { - fn respond_to(self, _: &rocket::request::Request<'_>) -> rocket::response::Result<'static> { - let mut response = Response::build() - .status(rocket::http::Status { - code: 307, - }) - .raw_header("Location", self.url) - .header(ContentType::HTML) - .finalize(); - - // Normal headers - response.set_raw_header("Referrer-Policy", "same-origin"); - response.set_raw_header("X-XSS-Protection", "0"); - - for header in &self.headers { - response.set_raw_header(header.0.clone(), header.1.clone()); - } - - Ok(response) - } -} - // Log all the routes from the main paths list, and the attachments endpoint // Effectively ignores, any static file route, and the alive endpoint const LOGGED_ROUTES: [&str; 7] = ["/api", "/admin", "/identity", "/icons", "/attachments", "/events", "/notifications"]; @@ -903,29 +878,3 @@ mod tests { }); } } - -pub struct CookieManager<'a> { - jar: &'a CookieJar<'a>, -} - -impl<'a> CookieManager<'a> { - pub fn new(jar: &'a CookieJar<'a>) -> Self { - Self { - jar, - } - } - - pub fn set_cookie(&self, name: String, value: String) { - let cookie = Cookie::build((name, value)).same_site(SameSite::Lax); - - self.jar.add(cookie) - } - - pub fn get_cookie(&self, name: String) -> Option { - self.jar.get(&name).map(|c| c.value().to_string()) - } - - pub fn delete_cookie(&self, name: String) { - self.jar.remove(Cookie::from(name)); - } -} From 6ab7428eeef7f835dd1e56c3ec1cc531f4d2292e Mon Sep 17 00:00:00 2001 From: Timshel Date: Mon, 15 Apr 2024 16:06:18 +0200 Subject: [PATCH 03/10] Stop rolling device token --- src/auth.rs | 3 +-- src/db/models/device.rs | 4 ---- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/src/auth.rs b/src/auth.rs index 4b5ecb9ed8..437d63312c 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1116,8 +1116,7 @@ pub async fn refresh_tokens(refresh_token: &str, conn: &mut DbConn) -> ApiResult Some(device) => device, }; - // Roll the Device.refresh_token this way it invalides old JWT refresh_token - device.roll_refresh_token(); + // Save to update `updated_at`. device.save(conn).await?; let user = match User::find_by_uuid(&device.user_uuid, conn).await { diff --git a/src/db/models/device.rs b/src/db/models/device.rs index 867754552f..0c4624caf2 100644 --- a/src/db/models/device.rs +++ b/src/db/models/device.rs @@ -48,10 +48,6 @@ impl Device { } } - pub fn roll_refresh_token(&mut self) { - self.refresh_token = crypto::encode_random_bytes::<64>(BASE64URL) - } - pub fn refresh_twofactor_remember(&mut self) -> String { let twofactor_remember = crypto::encode_random_bytes::<180>(BASE64); self.twofactor_remember = Some(twofactor_remember.clone()); From bee45318f7143f1dc6b828a63bee663fc1351b9f Mon Sep 17 00:00:00 2001 From: Timshel Date: Wed, 25 Sep 2024 15:42:05 +0200 Subject: [PATCH 04/10] Add playwright tests --- SSO.md | 7 + playwright/.env.template | 63 + playwright/.gitignore | 6 + playwright/README.md | 177 ++ playwright/compose/keycloak/Dockerfile | 40 + playwright/compose/keycloak/setup.sh | 36 + playwright/compose/playwright/Dockerfile | 40 + playwright/compose/vaultwarden/Dockerfile | 39 + playwright/compose/vaultwarden/build.sh | 24 + playwright/docker-compose.yml | 121 + playwright/global-setup.ts | 22 + playwright/global-utils.ts | 219 ++ playwright/package-lock.json | 2370 +++++++++++++++++ playwright/package.json | 21 + playwright/playwright.config.ts | 132 + playwright/test.env | 90 + playwright/tests/login.smtp.spec.ts | 159 ++ playwright/tests/login.spec.ts | 97 + playwright/tests/organization.spec.ts | 159 ++ playwright/tests/setups/db-setup.ts | 7 + playwright/tests/setups/db-teardown.ts | 11 + playwright/tests/setups/db-test.ts | 9 + playwright/tests/setups/sso-setup.ts | 19 + playwright/tests/setups/sso-teardown.ts | 15 + playwright/tests/setups/sso.ts | 82 + playwright/tests/setups/user.ts | 47 + playwright/tests/sso_login.spec.ts | 74 + playwright/tests/sso_organization.spec.ts | 152 ++ .../templates/email/send_org_invite.html.hbs | 2 +- .../templates/email/twofactor_email.html.hbs | 2 +- .../templates/email/verify_email.html.hbs | 2 +- 31 files changed, 4241 insertions(+), 3 deletions(-) create mode 100644 playwright/.env.template create mode 100644 playwright/.gitignore create mode 100644 playwright/README.md create mode 100644 playwright/compose/keycloak/Dockerfile create mode 100755 playwright/compose/keycloak/setup.sh create mode 100644 playwright/compose/playwright/Dockerfile create mode 100644 playwright/compose/vaultwarden/Dockerfile create mode 100755 playwright/compose/vaultwarden/build.sh create mode 100644 playwright/docker-compose.yml create mode 100644 playwright/global-setup.ts create mode 100644 playwright/global-utils.ts create mode 100644 playwright/package-lock.json create mode 100644 playwright/package.json create mode 100644 playwright/playwright.config.ts create mode 100644 playwright/test.env create mode 100644 playwright/tests/login.smtp.spec.ts create mode 100644 playwright/tests/login.spec.ts create mode 100644 playwright/tests/organization.spec.ts create mode 100644 playwright/tests/setups/db-setup.ts create mode 100644 playwright/tests/setups/db-teardown.ts create mode 100644 playwright/tests/setups/db-test.ts create mode 100644 playwright/tests/setups/sso-setup.ts create mode 100644 playwright/tests/setups/sso-teardown.ts create mode 100644 playwright/tests/setups/sso.ts create mode 100644 playwright/tests/setups/user.ts create mode 100644 playwright/tests/sso_login.spec.ts create mode 100644 playwright/tests/sso_organization.spec.ts diff --git a/SSO.md b/SSO.md index 0ccdc3498d..bfcdca9e8d 100644 --- a/SSO.md +++ b/SSO.md @@ -99,6 +99,13 @@ Server configuration, nothing specific just set: - `SSO_CLIENT_SECRET` - `SSO_PKCE=true` +### 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). diff --git a/playwright/.env.template b/playwright/.env.template new file mode 100644 index 0000000000..5b6c0c9e23 --- /dev/null +++ b/playwright/.env.template @@ -0,0 +1,63 @@ +################################# +### Conf to run dev instances ### +################################# +ENV=dev +DC_ENV_FILE=.env +COMPOSE_IGNORE_ORPHANS=True +DOCKER_BUILDKIT=1 + +################ +# Users Config # +################ +TEST_USER=test +TEST_USER_PASSWORD=${TEST_USER} +TEST_USER_MAIL=${TEST_USER}@yopmail.com + +TEST_USER2=test2 +TEST_USER2_PASSWORD=${TEST_USER2} +TEST_USER2_MAIL=${TEST_USER2}@yopmail.com + +TEST_USER3=test3 +TEST_USER3_PASSWORD=${TEST_USER3} +TEST_USER3_MAIL=${TEST_USER3}@yopmail.com + +################### +# Keycloak Config # +################### +KEYCLOAK_ADMIN=admin +KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN} +KC_HTTP_HOST=127.0.0.1 +KC_HTTP_PORT=8080 + +# Script parameters (use Keycloak and VaultWarden config too) +TEST_REALM=test +DUMMY_REALM=dummy +DUMMY_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${DUMMY_REALM} + +###################### +# Vaultwarden Config # +###################### +ROCKET_ADDRESS=0.0.0.0 +ROCKET_PORT=8000 +DOMAIN=http://127.0.0.1:${ROCKET_PORT} +I_REALLY_WANT_VOLATILE_STORAGE=true + +SSO_ENABLED=true +SSO_ONLY=false +SSO_CLIENT_ID=VaultWarden +SSO_CLIENT_SECRET=VaultWarden +SSO_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${TEST_REALM} + +SMTP_HOST=127.0.0.1 +SMTP_PORT=1025 +SMTP_SECURITY=off +SMTP_TIMEOUT=5 +SMTP_FROM=vaultwarden@test +SMTP_FROM_NAME=Vaultwarden + +######################################################## +# DUMMY values for docker-compose to stop bothering us # +######################################################## +MARIADB_PORT=3305 +MYSQL_PORT=3307 +POSTGRES_PORT=5432 diff --git a/playwright/.gitignore b/playwright/.gitignore new file mode 100644 index 0000000000..8746d597aa --- /dev/null +++ b/playwright/.gitignore @@ -0,0 +1,6 @@ +logs +node_modules/ +/test-results/ +/playwright-report/ +/playwright/.cache/ +temp diff --git a/playwright/README.md b/playwright/README.md new file mode 100644 index 0000000000..c470fbae52 --- /dev/null +++ b/playwright/README.md @@ -0,0 +1,177 @@ +# Integration tests + +This allows running integration tests using [Playwright](https://playwright.dev/). +\ +It usse its own [test.env](/test/scenarios/test.env) with different ports to not collide with a running dev instance. + +## Install + +This rely on `docker` and the `compose` [plugin](https://docs.docker.com/compose/install/). +Databases (`Mariadb`, `Mysql` and `Postgres`) and `Playwright` will run in containers. + +### Running Playwright outside docker + +It's possible to run `Playwright` outside of the container, this remove the need to rebuild the image for each change. +You'll additionally need `nodejs` then run: + +```bash +npm install +npx playwright install-deps +npx playwright install firefox +``` + +## Usage + +To run all the tests: + +```bash +DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright +``` + +To force a rebuild of the Playwright image: +```bash +DOCKER_BUILDKIT=1 docker compose --env-file test.env build Playwright +``` + +To access the ui to easily run test individually and debug if needed (will not work in docker): + +```bash +npx playwright test --ui +``` + +### DB + +Projects are configured to allow to run tests only on specific database. +\ +You can use: + +```bash +DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=mariadb +DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=mysql +DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=postgres +DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite +``` + +### SSO + +To run the SSO tests: + +```bash +DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project sso-sqlite +``` + +### Keep services running + +If you want you can keep the Db and Keycloak runnning (states are not impacted by the tests): + +```bash +PW_KEEP_SERVICE_RUNNNING=true npx playwright test +``` + +### Running specific tests + +To run a whole file you can : + +```bash +DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite tests/login.spec.ts +DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite login +``` + +To run only a specifc test (It might fail if it has dependency): + +```bash +DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite -g "Account creation" +DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite tests/login.spec.ts:16 +``` + +## Writing scenario + +When creating new scenario use the recorder to more easily identify elements (in general try to rely on visible hint to identify elements and not hidden ids). +This does not start the server, you will need to start it manually. + +```bash +npx playwright codegen "http://127.0.0.1:8000" +``` + +## Override web-vault + +It's possible to change the `web-vault` used by referencing a different `bw_web_builds` commit. + +```bash +export PW_WV_REPO_URL=https://github.com/Timshel/oidc_web_builds.git +export PW_WV_COMMIT_HASH=8707dc76df3f0cceef2be5bfae37bb29bd17fae6 +DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env build Playwright +``` + +# OpenID Connect test setup + +Additionnaly this `docker-compose` template allow to run locally `VaultWarden`, [Keycloak](https://www.keycloak.org/) and [Maildev](https://github.com/timshel/maildev) to test OIDC. + +## Setup + +This rely on `docker` and the `compose` [plugin](https://docs.docker.com/compose/install/). +First create a copy of `.env.template` as `.env` (This is done to prevent commiting your custom settings, Ex `SMTP_`). + +## Usage + +Then start the stack (the `profile` is required to run `Vaultwarden`) : + +```bash +> docker compose --profile vaultwarden --env-file .env up +.... +keycloakSetup_1 | Logging into http://127.0.0.1:8080 as user admin of realm master +keycloakSetup_1 | Created new realm with id 'test' +keycloakSetup_1 | 74af4933-e386-4e64-ba15-a7b61212c45e +oidc_keycloakSetup_1 exited with code 0 +``` + +Wait until `oidc_keycloakSetup_1 exited with code 0` which indicate the correct setup of the Keycloak realm, client and user (It's normal for this container to stop once the configuration is done). + +Then you can access : + +- `VaultWarden` on http://0.0.0.0:8000 with the default user `test@yopmail.com/test`. +- `Keycloak` on http://0.0.0.0:8080/admin/master/console/ with the default user `admin/admin` +- `Maildev` on http://0.0.0.0:1080 + +To proceed with an SSO login after you enter the email, on the screen prompting for `Master Password` the SSO button should be visible. +To use your computer external ip (for example when testing with a phone) you will have to configure `KC_HTTP_HOST` and `DOMAIN`. + +## Running only Keycloak + +You can run just `Keycloak` with `--profile keycloak`: + +```bash +> docker compose --profile keycloak --env-file .env up +``` + +When running with a local VaultWarden and the default `web-vault` you'll need to make the SSO button visible using : + +```bash +sed -i 's#a\[routerlink="/sso"\],##' web-vault/app/main.*.css +``` + +Otherwise you'll need to reveal the SSO login button using the debug console (F12) + + ```js + document.querySelector('a[routerlink="/sso"]').style.setProperty("display", "inline-block", "important"); + ``` + +## Rebuilding the Vaultwarden + +To force rebuilding the Vaultwarden image you can run + +```bash +docker compose --profile vaultwarden --env-file .env build VaultwardenPrebuild Vaultwarden +``` + +## Configuration + +All configuration for `keycloak` / `VaultWarden` / `keycloak_setup.sh` can be found in [.env](.env.template). +The content of the file will be loaded as environment variables in all containers. + +- `keycloak` [configuration](https://www.keycloak.org/server/all-config) include `KEYCLOAK_ADMIN` / `KEYCLOAK_ADMIN_PASSWORD` and any variable prefixed `KC_` ([more information](https://www.keycloak.org/server/configuration#_example_configuring_the_db_url_host_parameter)). +- All `VaultWarden` configuration can be set (EX: `SMTP_*`) + +## Cleanup + +Use `docker compose --profile vaultWarden down`. diff --git a/playwright/compose/keycloak/Dockerfile b/playwright/compose/keycloak/Dockerfile new file mode 100644 index 0000000000..3588895016 --- /dev/null +++ b/playwright/compose/keycloak/Dockerfile @@ -0,0 +1,40 @@ +FROM docker.io/library/debian:bookworm-slim as build + +ENV DEBIAN_FRONTEND=noninteractive +ARG KEYCLOAK_VERSION + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +RUN apt-get update \ + && apt-get install -y ca-certificates curl wget \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR / + +RUN wget -c https://github.com/keycloak/keycloak/releases/download/${KEYCLOAK_VERSION}/keycloak-${KEYCLOAK_VERSION}.tar.gz -O - | tar -xz + +FROM docker.io/library/debian:bookworm-slim + +ENV DEBIAN_FRONTEND=noninteractive +ARG KEYCLOAK_VERSION + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +RUN apt-get update \ + && apt-get install -y ca-certificates curl wget \ + && rm -rf /var/lib/apt/lists/* + +ARG JAVA_URL +ARG JAVA_VERSION + +ENV JAVA_VERSION=${JAVA_VERSION} + +RUN mkdir -p /opt/openjdk && cd /opt/openjdk \ + && wget -c "${JAVA_URL}" -O - | tar -xz + +WORKDIR / + +COPY setup.sh /setup.sh +COPY --from=build /keycloak-${KEYCLOAK_VERSION}/bin /opt/keycloak/bin + +CMD "/setup.sh" diff --git a/playwright/compose/keycloak/setup.sh b/playwright/compose/keycloak/setup.sh new file mode 100755 index 0000000000..36597b1d21 --- /dev/null +++ b/playwright/compose/keycloak/setup.sh @@ -0,0 +1,36 @@ +#!/bin/bash + +export PATH=/opt/keycloak/bin:/opt/openjdk/jdk-${JAVA_VERSION}/bin:$PATH +export JAVA_HOME=/opt/openjdk/jdk-${JAVA_VERSION} + +STATUS_CODE=0 +while [[ "$STATUS_CODE" != "404" ]] ; do + echo "Will retry in 2 seconds" + sleep 2 + + STATUS_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$DUMMY_AUTHORITY") + + if [[ "$STATUS_CODE" = "200" ]]; then + echo "Setup should already be done. Will not run." + exit 0 + fi +done + +set -e + +kcadm.sh config credentials --server "http://${KC_HTTP_HOST}:${KC_HTTP_PORT}" --realm master --user "$KEYCLOAK_ADMIN" --password "$KEYCLOAK_ADMIN_PASSWORD" --client admin-cli + +kcadm.sh create realms -s realm="$TEST_REALM" -s enabled=true -s "accessTokenLifespan=600" +kcadm.sh create clients -r test -s "clientId=$SSO_CLIENT_ID" -s "secret=$SSO_CLIENT_SECRET" -s "redirectUris=[\"$DOMAIN/*\"]" -i + +TEST_USER_ID=$(kcadm.sh create users -r "$TEST_REALM" -s "username=$TEST_USER" -s "firstName=$TEST_USER" -s "lastName=$TEST_USER" -s "email=$TEST_USER_MAIL" -s emailVerified=true -s enabled=true -i) +kcadm.sh update users/$TEST_USER_ID/reset-password -r "$TEST_REALM" -s type=password -s "value=$TEST_USER_PASSWORD" -n + +TEST_USER2_ID=$(kcadm.sh create users -r "$TEST_REALM" -s "username=$TEST_USER2" -s "firstName=$TEST_USER2" -s "lastName=$TEST_USER2" -s "email=$TEST_USER2_MAIL" -s emailVerified=true -s enabled=true -i) +kcadm.sh update users/$TEST_USER2_ID/reset-password -r "$TEST_REALM" -s type=password -s "value=$TEST_USER2_PASSWORD" -n + +TEST_USER3_ID=$(kcadm.sh create users -r "$TEST_REALM" -s "username=$TEST_USER3" -s "firstName=$TEST_USER3" -s "lastName=$TEST_USER3" -s "email=$TEST_USER3_MAIL" -s emailVerified=true -s enabled=true -i) +kcadm.sh update users/$TEST_USER3_ID/reset-password -r "$TEST_REALM" -s type=password -s "value=$TEST_USER3_PASSWORD" -n + +# Dummy realm to mark end of setup +kcadm.sh create realms -s realm="$DUMMY_REALM" -s enabled=true -s "accessTokenLifespan=600" diff --git a/playwright/compose/playwright/Dockerfile b/playwright/compose/playwright/Dockerfile new file mode 100644 index 0000000000..1a4b1ddb7f --- /dev/null +++ b/playwright/compose/playwright/Dockerfile @@ -0,0 +1,40 @@ +FROM docker.io/library/debian:bookworm-slim + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update \ + && apt-get install -y ca-certificates curl \ + && curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc \ + && chmod a+r /etc/apt/keyrings/docker.asc \ + && echo "deb [signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable" | tee /etc/apt/sources.list.d/docker.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + containerd.io \ + docker-buildx-plugin \ + docker-ce \ + docker-ce-cli \ + docker-compose-plugin \ + git \ + libmariadb-dev-compat \ + libpq5 \ + nodejs \ + npm \ + openssl \ + && rm -rf /var/lib/apt/lists/* + +RUN mkdir /playwright +WORKDIR /playwright + +COPY package.json . +RUN npm install && npx playwright install-deps && npx playwright install firefox + +COPY docker-compose.yml test.env ./ +COPY compose ./compose + +COPY *.ts test.env ./ +COPY tests ./tests + +ENTRYPOINT ["/usr/bin/npx", "playwright"] +CMD ["test"] diff --git a/playwright/compose/vaultwarden/Dockerfile b/playwright/compose/vaultwarden/Dockerfile new file mode 100644 index 0000000000..4606ae36c2 --- /dev/null +++ b/playwright/compose/vaultwarden/Dockerfile @@ -0,0 +1,39 @@ +FROM playwright_oidc_vaultwarden_prebuilt AS vaultwarden + +FROM node:18-bookworm AS build + +arg REPO_URL +arg COMMIT_HASH + +ENV REPO_URL=$REPO_URL +ENV COMMIT_HASH=$COMMIT_HASH + +COPY --from=vaultwarden /web-vault /web-vault +COPY build.sh /build.sh +RUN /build.sh + +######################## RUNTIME IMAGE ######################## +FROM docker.io/library/debian:bookworm-slim + +ENV DEBIAN_FRONTEND=noninteractive + +# Create data folder and Install needed libraries +RUN mkdir /data && \ + apt-get update && apt-get install -y \ + --no-install-recommends \ + ca-certificates \ + curl \ + libmariadb-dev-compat \ + libpq5 \ + openssl && \ + rm -rf /var/lib/apt/lists/* + +# Copies the files from the context (Rocket.toml file and web-vault) +# and the binary from the "build" stage to the current stage +WORKDIR / + +COPY --from=vaultwarden /start.sh . +COPY --from=vaultwarden /vaultwarden . +COPY --from=build /web-vault ./web-vault + +ENTRYPOINT ["/start.sh"] diff --git a/playwright/compose/vaultwarden/build.sh b/playwright/compose/vaultwarden/build.sh new file mode 100755 index 0000000000..da35411291 --- /dev/null +++ b/playwright/compose/vaultwarden/build.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +echo $REPO_URL +echo $COMMIT_HASH + +if [[ ! -z "$REPO_URL" ]] && [[ ! -z "$COMMIT_HASH" ]] ; then + rm -rf /web-vault + + mkdir bw_web_builds; + cd bw_web_builds; + + git -c init.defaultBranch=main init + git remote add origin "$REPO_URL" + git fetch --depth 1 origin "$COMMIT_HASH" + git -c advice.detachedHead=false checkout FETCH_HEAD + + export VAULT_VERSION=$(cat Dockerfile | grep "ARG VAULT_VERSION" | cut -d "=" -f2) + ./scripts/checkout_web_vault.sh + ./scripts/patch_web_vault.sh + ./scripts/build_web_vault.sh + printf '{"version":"%s"}' "$COMMIT_HASH" > ./web-vault/apps/web/build/vw-version.json + + mv ./web-vault/apps/web/build /web-vault +fi diff --git a/playwright/docker-compose.yml b/playwright/docker-compose.yml new file mode 100644 index 0000000000..ea2ab5e942 --- /dev/null +++ b/playwright/docker-compose.yml @@ -0,0 +1,121 @@ +services: + VaultwardenPrebuild: + profiles: ["playwright", "vaultwarden"] + container_name: playwright_oidc_vaultwarden_prebuilt + image: playwright_oidc_vaultwarden_prebuilt + build: + context: .. + dockerfile: Dockerfile + entrypoint: /bin/bash + restart: "no" + + Vaultwarden: + profiles: ["playwright", "vaultwarden"] + container_name: playwright_oidc_vaultwarden-${ENV:-dev} + image: playwright_oidc_vaultwarden-${ENV:-dev} + network_mode: "host" + build: + context: compose/vaultwarden + dockerfile: Dockerfile + args: + REPO_URL: ${PW_WV_REPO_URL:-} + COMMIT_HASH: ${PW_WV_COMMIT_HASH:-} + env_file: ${DC_ENV_FILE:-.env} + environment: + - DATABASE_URL + - I_REALLY_WANT_VOLATILE_STORAGE + - SMTP_HOST + - SMTP_FROM + - SMTP_DEBUG + - SSO_FRONTEND + - SSO_ENABLED + - SSO_ONLY + restart: "no" + depends_on: + - VaultwardenPrebuild + + Playwright: + profiles: ["playwright"] + container_name: playwright_oidc_playwright + image: playwright_oidc_playwright + network_mode: "host" + build: + context: . + dockerfile: compose/playwright/Dockerfile + environment: + - PW_WV_REPO_URL + - PW_WV_COMMIT_HASH + restart: "no" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ..:/project + + Mariadb: + profiles: ["playwright"] + container_name: playwright_mariadb + image: mariadb:11.2.4 + env_file: test.env + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + start_period: 10s + interval: 10s + ports: + - ${MARIADB_PORT}:3306 + + Mysql: + profiles: ["playwright"] + container_name: playwright_mysql + image: mysql:8.4.1 + env_file: test.env + healthcheck: + test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"] + start_period: 10s + interval: 10s + ports: + - ${MYSQL_PORT}:3306 + + Postgres: + profiles: ["playwright"] + container_name: playwright_postgres + image: postgres:16.3 + env_file: test.env + healthcheck: + test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"] + start_period: 20s + interval: 30s + ports: + - ${POSTGRES_PORT}:5432 + + Maildev: + profiles: ["vaultwarden", "maildev"] + container_name: maildev + image: timshel/maildev + ports: + - ${SMTP_PORT}:1025 + - 1080:1080 + + Keycloak: + profiles: ["keycloak", "vaultwarden"] + container_name: keycloak-${ENV:-dev} + image: quay.io/keycloak/keycloak:25.0.4 + network_mode: "host" + command: + - start-dev + env_file: ${DC_ENV_FILE:-.env} + + KeycloakSetup: + profiles: ["keycloak", "vaultwarden"] + container_name: keycloakSetup-${ENV:-dev} + image: keycloak_setup-${ENV:-dev} + build: + context: compose/keycloak + dockerfile: Dockerfile + args: + KEYCLOAK_VERSION: 25.0.4 + JAVA_URL: https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_linux-x64_bin.tar.gz + JAVA_VERSION: 21.0.2 + network_mode: "host" + depends_on: + - Keycloak + restart: "no" + env_file: ${DC_ENV_FILE:-.env} diff --git a/playwright/global-setup.ts b/playwright/global-setup.ts new file mode 100644 index 0000000000..89405f125f --- /dev/null +++ b/playwright/global-setup.ts @@ -0,0 +1,22 @@ +import { firefox, type FullConfig } from '@playwright/test'; +import { execSync } from 'node:child_process'; +import fs from 'fs'; + +const utils = require('./global-utils'); + +utils.loadEnv(); + +async function globalSetup(config: FullConfig) { + // Are we running in docker and the project is mounted ? + const path = (fs.existsSync("/project/playwright/playwright.config.ts") ? "/project/playwright" : "."); + execSync(`docker compose --project-directory ${path} --profile playwright --env-file test.env build VaultwardenPrebuild`, { + env: { ...process.env }, + stdio: "inherit" + }); + execSync(`docker compose --project-directory ${path} --profile playwright --env-file test.env build Vaultwarden`, { + env: { ...process.env }, + stdio: "inherit" + }); +} + +export default globalSetup; diff --git a/playwright/global-utils.ts b/playwright/global-utils.ts new file mode 100644 index 0000000000..724b08772f --- /dev/null +++ b/playwright/global-utils.ts @@ -0,0 +1,219 @@ +import { type Browser, type TestInfo } from '@playwright/test'; +import { EventEmitter } from "events"; +import { type Mail, MailServer } from 'maildev'; +import { execSync } from 'node:child_process'; + +import dotenv from 'dotenv'; +import dotenvExpand from 'dotenv-expand'; + +const fs = require("fs"); +const { spawn } = require('node:child_process'); + +export function loadEnv(){ + var myEnv = dotenv.config({ path: 'test.env' }); + dotenvExpand.expand(myEnv); + + return { + user1: { + email: process.env.TEST_USER_MAIL, + name: process.env.TEST_USER, + password: process.env.TEST_USER_PASSWORD, + }, + user2: { + email: process.env.TEST_USER2_MAIL, + name: process.env.TEST_USER2, + password: process.env.TEST_USER2_PASSWORD, + }, + user3: { + email: process.env.TEST_USER3_MAIL, + name: process.env.TEST_USER3, + password: process.env.TEST_USER3_PASSWORD, + }, + } +} + +export function closeMails(mailServer: MailServer, mailIterators: AsyncIterator[]) { + if( mailServer ) { + mailServer.close(); + } + if( mailIterators ) { + for (const mails of mailIterators) { + if(mails){ + mails.return(); + } + } + } +} + +export async function waitFor(url: String, browser: Browser) { + var ready = false; + var context; + + do { + try { + context = await browser.newContext(); + const page = await context.newPage(); + await page.waitForTimeout(500); + const result = await page.goto(url); + ready = result.status() === 200; + } catch(e) { + if( !e.message.includes("CONNECTION_REFUSED") ){ + throw e; + } + } finally { + await context.close(); + } + } while(!ready); +} + +export function startComposeService(serviceName: String){ + console.log(`Starting ${serviceName}`); + execSync(`docker compose --profile playwright --env-file test.env up -d ${serviceName}`); +} + +export function stopComposeService(serviceName: String){ + console.log(`Stopping ${serviceName}`); + execSync(`docker compose --profile playwright --env-file test.env stop ${serviceName}`); +} + +function wipeSqlite(){ + console.log(`Delete Vaultwarden container to wipe sqlite`); + execSync(`docker compose --env-file test.env stop Vaultwarden`); + execSync(`docker compose --env-file test.env rm -f Vaultwarden`); +} + +async function wipeMariaDB(){ + var mysql = require('mysql2/promise'); + var ready = false; + var connection; + + do { + try { + connection = await mysql.createConnection({ + user: process.env.MARIADB_USER, + host: "127.0.0.1", + database: process.env.MARIADB_DATABASE, + password: process.env.MARIADB_PASSWORD, + port: process.env.MARIADB_PORT, + }); + + await connection.execute(`DROP DATABASE ${process.env.MARIADB_DATABASE}`); + await connection.execute(`CREATE DATABASE ${process.env.MARIADB_DATABASE}`); + console.log('Successfully wiped mariadb'); + ready = true; + } catch (err) { + console.log(`Error when wiping mariadb: ${err}`); + } finally { + if( connection ){ + connection.end(); + } + } + await new Promise(r => setTimeout(r, 1000)); + } while(!ready); +} + +async function wipeMysqlDB(){ + var mysql = require('mysql2/promise'); + var ready = false; + var connection; + + do{ + try { + connection = await mysql.createConnection({ + user: process.env.MYSQL_USER, + host: "127.0.0.1", + database: process.env.MYSQL_DATABASE, + password: process.env.MYSQL_PASSWORD, + port: process.env.MYSQL_PORT, + }); + + await connection.execute(`DROP DATABASE ${process.env.MYSQL_DATABASE}`); + await connection.execute(`CREATE DATABASE ${process.env.MYSQL_DATABASE}`); + console.log('Successfully wiped mysql'); + ready = true; + } catch (err) { + console.log(`Error when wiping mysql: ${err}`); + } finally { + if( connection ){ + connection.end(); + } + } + await new Promise(r => setTimeout(r, 1000)); + } while(!ready); +} + +async function wipePostgres(){ + const { Client } = require('pg'); + + const client = new Client({ + user: process.env.POSTGRES_USER, + host: "127.0.0.1", + database: "postgres", + password: process.env.POSTGRES_PASSWORD, + port: process.env.POSTGRES_PORT, + }); + + try { + await client.connect(); + await client.query(`DROP DATABASE ${process.env.POSTGRES_DB}`); + await client.query(`CREATE DATABASE ${process.env.POSTGRES_DB}`); + console.log('Successfully wiped postgres'); + } catch (err) { + console.log(`Error when wiping postgres: ${err}`); + } finally { + client.end(); + } +} + +function dbConfig(testInfo: TestInfo){ + switch(testInfo.project.name) { + case "postgres": return { + DATABASE_URL: `postgresql://${process.env.POSTGRES_USER}:${process.env.POSTGRES_PASSWORD}@127.0.0.1:${process.env.POSTGRES_PORT}/${process.env.POSTGRES_DB}` + } + case "mariadb": return { + DATABASE_URL: `mysql://${process.env.MARIADB_USER}:${process.env.MARIADB_PASSWORD}@127.0.0.1:${process.env.MARIADB_PORT}/${process.env.MARIADB_DATABASE}` + } + case "mysql": return { + DATABASE_URL: `mysql://${process.env.MYSQL_USER}:${process.env.MYSQL_PASSWORD}@127.0.0.1:${process.env.MYSQL_PORT}/${process.env.MYSQL_DATABASE}` + } + default: return { I_REALLY_WANT_VOLATILE_STORAGE: true } + } +} + +/** + * All parameters passed in `env` need to be added to the docker-compose.yml + **/ +export async function startVaultwarden(browser: Browser, testInfo: TestInfo, env = {}, resetDB: Boolean = true) { + if( resetDB ){ + switch(testInfo.project.name) { + case "postgres": + await wipePostgres(); + break; + case "mariadb": + await wipeMariaDB(); + break; + case "mysql": + await wipeMysqlDB(); + break; + default: + wipeSqlite(); + } + } + + console.log(`Starting Vaultwarden`); + execSync(`docker compose --profile playwright --env-file test.env up -d Vaultwarden`, { + env: { ...env, ...dbConfig(testInfo) }, + }); + await waitFor("/", browser); + console.log(`Vaultwarden running on: ${process.env.DOMAIN}`); +} + +export async function stopVaultwarden() { + console.log(`Vaultwarden stopping`); + execSync(`docker compose --profile playwright --env-file test.env stop Vaultwarden`); +} + +export async function restartVaultwarden(page: Page, testInfo: TestInfo, env, resetDB: Boolean = true) { + stopVaultwarden(); + return startVaultwarden(page.context().browser(), testInfo, env, resetDB); +} diff --git a/playwright/package-lock.json b/playwright/package-lock.json new file mode 100644 index 0000000000..96a4137b17 --- /dev/null +++ b/playwright/package-lock.json @@ -0,0 +1,2370 @@ +{ + "name": "scenarios", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "scenarios", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "mysql2": "^3.10.2", + "otpauth": "^9.3.2", + "pg": "^8.12.0" + }, + "devDependencies": { + "@playwright/test": "^1.45.1", + "dotenv": "^16.4.5", + "dotenv-expand": "^11.0.6", + "maildev": "github:timshel/maildev#3.0.0-rc1" + } + }, + "node_modules/@noble/hashes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@playwright/test": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.45.1.tgz", + "integrity": "sha512-Wo1bWTzQvGA7LyKGIZc8nFSTFf2TkthGIFBR+QVNilvwouGzFd4PYukZe3rvf5PSqjHi1+1NyKSDZKcQWETzaA==", + "dev": true, + "dependencies": { + "playwright": "1.45.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@selderee/plugin-htmlparser2": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz", + "integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==", + "dev": true, + "dependencies": { + "domhandler": "^5.0.3", + "selderee": "^0.11.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "dev": true + }, + "node_modules/@types/cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==", + "dev": true + }, + "node_modules/@types/cors": { + "version": "2.8.17", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz", + "integrity": "sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/mailparser": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/@types/mailparser/-/mailparser-3.4.4.tgz", + "integrity": "sha512-C6Znp2QVS25JqtuPyxj38Qh+QoFcLycdxsvcc6IZCGekhaMBzbdTXzwGzhGoYb3TfKu8IRCNV0sV1o3Od97cEQ==", + "dev": true, + "dependencies": { + "@types/node": "*", + "iconv-lite": "^0.6.3" + } + }, + "node_modules/@types/node": { + "version": "22.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.1.0.tgz", + "integrity": "sha512-AOmuRF0R2/5j1knA3c6G3HOk523Ga+l+ZXltX8SF1+5oqcXijjfTd8fY3XRZqSihEu9XhtQnKYLmkFaoxgsJHw==", + "dev": true, + "dependencies": { + "undici-types": "~6.13.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dev": true, + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/addressparser": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/addressparser/-/addressparser-1.0.1.tgz", + "integrity": "sha512-aQX7AISOMM7HFE0iZ3+YnD07oIeJqWGVnJ+ZIKaBZAk03ftmVYVqsGas/rbXKR21n4D/hKCSHypvcyOkds/xzg==", + "dev": true + }, + "node_modules/agent-base": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", + "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", + "dev": true, + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/agent-base/node_modules/debug": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", + "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/agent-base/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "dev": true + }, + "node_modules/async": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", + "integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==", + "dev": true + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true + }, + "node_modules/base32.js": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.1.0.tgz", + "integrity": "sha512-n3TkB02ixgBOhTvANakDb4xaMXnYUVkNoRFJjQflcqMQhyEKxEHdj3E6N8t8sUQ0mjH/3/JxzlXuz3ul/J90pQ==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "dev": true, + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, + "node_modules/body-parser": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", + "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", + "dev": true, + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.11.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "dev": true, + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.4.tgz", + "integrity": "sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==", + "dev": true, + "dependencies": { + "accepts": "~1.3.5", + "bytes": "3.0.0", + "compressible": "~2.0.16", + "debug": "2.6.9", + "on-headers": "~1.0.2", + "safe-buffer": "5.1.2", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dev": true, + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-disposition/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "dev": true + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "dev": true, + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/cssstyle": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.0.1.tgz", + "integrity": "sha512-8ZYiJ3A/3OkDd093CBT/0UKDWry7ak4BdPTFP2+QEP7cmhouyq/Up709ASSj2cK02BbZiMgk7kYjZNS4QP5qrQ==", + "dev": true, + "dependencies": { + "rrweb-cssom": "^0.6.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz", + "integrity": "sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==", + "dev": true + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decimal.js": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", + "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==", + "dev": true + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "dev": true, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ] + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/dompurify": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.6.tgz", + "integrity": "sha512-cTOAhc36AalkjtBpfG6O8JimdTMWNXjiePT2xQH/ppBGi/4uIpmj8eKyIkMJErXWARyINV/sB38yf8JCLF5pbQ==", + "dev": true + }, + "node_modules/domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "dev": true, + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dotenv": { + "version": "16.4.5", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", + "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "11.0.6", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.6.tgz", + "integrity": "sha512-8NHi73otpWsZGBSZwwknTXS5pqMOrk9+Ssrna8xCaxkzEpU9OTf9R5ArQGVw03//Zmk9MOwLPng9WwndvpAJ5g==", + "dev": true, + "dependencies": { + "dotenv": "^16.4.4" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true + }, + "node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/encoding-japanese": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.1.0.tgz", + "integrity": "sha512-58XySVxUgVlBikBTbQ8WdDxBDHIdXucB16LO5PBHR8t75D54wQrNo4cg+58+R1CtJfKnsVsvt9XlteRaR8xw1w==", + "dev": true, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/engine.io": { + "version": "6.5.5", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.5.5.tgz", + "integrity": "sha512-C5Pn8Wk+1vKBoHghJODM63yk8MvrO9EWZUfkAt5HAqIgPE4/8FF0PEGHXtEd40l223+cE5ABWuPzm38PHFXfMA==", + "dev": true, + "dependencies": { + "@types/cookie": "^0.4.1", + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.4.1", + "cors": "~2.8.5", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.17.1" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "dev": true, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/cookie": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", + "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/debug": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", + "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/engine.io/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/engine.io/node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.19.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", + "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", + "dev": true, + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.2", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.6.0", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.2.0", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.7", + "qs": "6.11.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.18.0", + "serve-static": "1.15.0", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/express/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/finalhandler": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", + "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "dev": true, + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dev": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", + "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "bin": { + "he": "bin/he" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/html-to-text": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz", + "integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==", + "dev": true, + "dependencies": { + "@selderee/plugin-htmlparser2": "^0.11.0", + "deepmerge": "^4.3.1", + "dom-serializer": "^2.0.0", + "htmlparser2": "^8.0.2", + "selderee": "^0.11.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dev": true, + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent/node_modules/debug": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", + "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/http-proxy-agent/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/https-proxy-agent": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz", + "integrity": "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==", + "dev": true, + "dependencies": { + "agent-base": "^7.0.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", + "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/ipv6-normalize": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ipv6-normalize/-/ipv6-normalize-1.0.1.tgz", + "integrity": "sha512-Bm6H79i01DjgGTCWjUuCjJ6QDo1HB96PT/xCYuyJUP9WFbVDrLSbG4EZCvOCun2rNswZb0c3e4Jt/ws795esHA==", + "dev": true + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==" + }, + "node_modules/jsdom": { + "version": "24.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.1.1.tgz", + "integrity": "sha512-5O1wWV99Jhq4DV7rCLIoZ/UIhyQeDR7wHVyZAHAshbrvZsLs+Xzz7gtwnlJTJDjleiTKh54F4dXrX70vJQTyJQ==", + "dev": true, + "dependencies": { + "cssstyle": "^4.0.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.4", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/leac": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz", + "integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==", + "dev": true, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, + "node_modules/libbase64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz", + "integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==", + "dev": true + }, + "node_modules/libmime": { + "version": "5.3.5", + "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.5.tgz", + "integrity": "sha512-nSlR1yRZ43L3cZCiWEw7ali3jY29Hz9CQQ96Oy+sSspYnIP5N54ucOPHqooBsXzwrX1pwn13VUE05q4WmzfaLg==", + "dev": true, + "dependencies": { + "encoding-japanese": "2.1.0", + "iconv-lite": "0.6.3", + "libbase64": "1.3.0", + "libqp": "2.1.0" + } + }, + "node_modules/libqp": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.0.tgz", + "integrity": "sha512-O6O6/fsG5jiUVbvdgT7YX3xY3uIadR6wEZ7+vy9u7PKHAlSEB6blvC1o5pHBjgsi95Uo0aiBBdkyFecj6jtb7A==", + "dev": true + }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "dev": true, + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/long": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz", + "integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==" + }, + "node_modules/lru-cache": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-8.0.5.tgz", + "integrity": "sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA==", + "engines": { + "node": ">=16.14" + } + }, + "node_modules/maildev": { + "version": "3.0.0-rc0", + "resolved": "git+ssh://git@github.com/timshel/maildev.git#b18050e3b980886f093a068a7453446610fdf12a", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mailparser": "^3.4.4", + "addressparser": "1.0.1", + "async": "^3.2.3", + "commander": "^12.1.0", + "compression": "^1.7.4", + "cors": "^2.8.5", + "dompurify": "^3.1.6", + "express": "^4.19.2", + "jsdom": "^24.1.1", + "mailparser": "^3.7.1", + "mime": "1.6.0", + "nodemailer": "^6.9.14", + "smtp-server": "^3.13.4", + "socket.io": "^4.7.5", + "wildstring": "1.0.9" + }, + "bin": { + "maildev": "bin/maildev" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/mailparser": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.7.1.tgz", + "integrity": "sha512-RCnBhy5q8XtB3mXzxcAfT1huNqN93HTYYyL6XawlIKycfxM/rXPg9tXoZ7D46+SgCS1zxKzw+BayDQSvncSTTw==", + "dev": true, + "dependencies": { + "encoding-japanese": "2.1.0", + "he": "1.2.0", + "html-to-text": "9.0.5", + "iconv-lite": "0.6.3", + "libmime": "5.3.5", + "linkify-it": "5.0.0", + "mailsplit": "5.4.0", + "nodemailer": "6.9.13", + "punycode.js": "2.3.1", + "tlds": "1.252.0" + } + }, + "node_modules/mailparser/node_modules/nodemailer": { + "version": "6.9.13", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.13.tgz", + "integrity": "sha512-7o38Yogx6krdoBf3jCAqnIN4oSQFx+fMa0I7dK1D+me9kBxx12D+/33wSb+fhOCtIxvYJ+4x4IMEhmhCKfAiOA==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/mailsplit": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/mailsplit/-/mailsplit-5.4.0.tgz", + "integrity": "sha512-wnYxX5D5qymGIPYLwnp6h8n1+6P6vz/MJn5AzGjZ8pwICWssL+CCQjWBIToOVHASmATot4ktvlLo6CyLfOXWYA==", + "dev": true, + "dependencies": { + "libbase64": "1.2.1", + "libmime": "5.2.0", + "libqp": "2.0.1" + } + }, + "node_modules/mailsplit/node_modules/encoding-japanese": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.0.0.tgz", + "integrity": "sha512-++P0RhebUC8MJAwJOsT93dT+5oc5oPImp1HubZpAuCZ5kTLnhuuBhKHj2jJeO/Gj93idPBWmIuQ9QWMe5rX3pQ==", + "dev": true, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/mailsplit/node_modules/libbase64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.2.1.tgz", + "integrity": "sha512-l+nePcPbIG1fNlqMzrh68MLkX/gTxk/+vdvAb388Ssi7UuUN31MI44w4Yf33mM3Cm4xDfw48mdf3rkdHszLNew==", + "dev": true + }, + "node_modules/mailsplit/node_modules/libmime": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.2.0.tgz", + "integrity": "sha512-X2U5Wx0YmK0rXFbk67ASMeqYIkZ6E5vY7pNWRKtnNzqjvdYYG8xtPDpCnuUEnPU9vlgNev+JoSrcaKSUaNvfsw==", + "dev": true, + "dependencies": { + "encoding-japanese": "2.0.0", + "iconv-lite": "0.6.3", + "libbase64": "1.2.1", + "libqp": "2.0.1" + } + }, + "node_modules/mailsplit/node_modules/libqp": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/libqp/-/libqp-2.0.1.tgz", + "integrity": "sha512-Ka0eC5LkF3IPNQHJmYBWljJsw0UvM6j+QdKRbWyCdTmYwvIDE6a7bCm0UkTAL/K+3KXK5qXT/ClcInU01OpdLg==", + "dev": true + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==", + "dev": true + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.53.0.tgz", + "integrity": "sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + }, + "node_modules/mysql2": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.10.2.tgz", + "integrity": "sha512-KCXPEvAkO0RcHPr362O5N8tFY2fXvbjfkPvRY/wGumh4EOemo9Hm5FjQZqv/pCmrnuxGu5OxnSENG0gTXqKMgQ==", + "dependencies": { + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.6.3", + "long": "^5.2.1", + "lru-cache": "^8.0.0", + "named-placeholders": "^1.1.3", + "seq-queue": "^0.0.5", + "sqlstring": "^2.3.2" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.3.tgz", + "integrity": "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==", + "dependencies": { + "lru-cache": "^7.14.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/named-placeholders/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nodemailer": { + "version": "6.9.14", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.14.tgz", + "integrity": "sha512-Dobp/ebDKBvz91sbtRKhcznLThrKxKt97GI2FAlAyy+fk19j73Uz3sBXolVtmcXjaorivqsbbbjDY+Jkt4/bQA==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/nwsapi": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.12.tgz", + "integrity": "sha512-qXDmcVlZV4XRtKFzddidpfVP4oMSGhga+xdMc25mv8kaLUHtgzCDhUxkrN8exkGdTlLNaXj7CV3GtON7zuGZ+w==", + "dev": true + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", + "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/otpauth": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.3.2.tgz", + "integrity": "sha512-KixtXWN9RGdS8WHPfDo7qsOYiivCbl+VeLBT+7HBTtJebBO6aXr/bpZXr+TwY2COecdY82VeBghm31mLYQVZlQ==", + "dependencies": { + "@noble/hashes": "1.4.0" + }, + "funding": { + "url": "https://github.com/hectorm/otpauth?sponsor=1" + } + }, + "node_modules/parse5": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", + "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", + "dev": true, + "dependencies": { + "entities": "^4.4.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parseley": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz", + "integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==", + "dev": true, + "dependencies": { + "leac": "^0.6.0", + "peberminta": "^0.9.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==", + "dev": true + }, + "node_modules/peberminta": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz", + "integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==", + "dev": true, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, + "node_modules/pg": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.12.0.tgz", + "integrity": "sha512-A+LHUSnwnxrnL/tZ+OLfqR1SxLN3c/pgDztZ47Rpbsd4jUytsTtwQo/TLPRzPJMp/1pbhYVhH9cuSZLAajNfjQ==", + "dependencies": { + "pg-connection-string": "^2.6.4", + "pg-pool": "^3.6.2", + "pg-protocol": "^1.6.1", + "pg-types": "^2.1.0", + "pgpass": "1.x" + }, + "engines": { + "node": ">= 8.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.1.1" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.1.1.tgz", + "integrity": "sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.4.tgz", + "integrity": "sha512-v+Z7W/0EO707aNMaAEfiGnGL9sxxumwLl2fJvCQtMn9Fxsg+lPpPkdcyBSv/KFgpGdYkMfn+EI1Or2EHjpgLCA==" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.6.2.tgz", + "integrity": "sha512-Htjbg8BlwXqSBQ9V8Vjtc+vzf/6fVUuak/3/XXKA9oxZprwW3IMDQTGHP+KDmVL7rtd+R1QjbnCFPuTHm3G4hg==", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.6.1.tgz", + "integrity": "sha512-jPIlvgoD63hrEuihvIg+tJhoGjUsLPn6poJY9N5CnlPd91c2T18T/9zBtLxZSb1EhYxBRoZJtzScCaWlYLtktg==" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/playwright": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.45.1.tgz", + "integrity": "sha512-Hjrgae4kpSQBr98nhCj3IScxVeVUixqj+5oyif8TdIn2opTCPEzqAqNMeK42i3cWDCVu9MI+ZsGWw+gVR4ISBg==", + "dev": true, + "dependencies": { + "playwright-core": "1.45.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.45.1.tgz", + "integrity": "sha512-LF4CUUtrUu2TCpDw4mcrAIuYrEjVDfT1cHbJMfwnE2+1b8PZcFzPNgvZCvq2JfQ4aTjRCCHw5EJ2tmr2NSzdPg==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", + "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/psl": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", + "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==", + "dev": true + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "dev": true, + "dependencies": { + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "dev": true, + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-body/node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true + }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/selderee": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz", + "integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==", + "dev": true, + "dependencies": { + "parseley": "^0.12.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, + "node_modules/send": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", + "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "dev": true, + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/seq-queue": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==" + }, + "node_modules/serve-static": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", + "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "dev": true, + "dependencies": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.18.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true + }, + "node_modules/side-channel": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/smtp-server": { + "version": "3.13.4", + "resolved": "https://registry.npmjs.org/smtp-server/-/smtp-server-3.13.4.tgz", + "integrity": "sha512-BbElv5UP+HgPtCZtcRW35N/GFoc4DzPkrbSMLioXsrVMmQT1mMBoO0k+egl264hxWaWczoVvadSPY2pLUINFXg==", + "dev": true, + "dependencies": { + "base32.js": "0.1.0", + "ipv6-normalize": "1.0.1", + "nodemailer": "6.9.13", + "punycode": "2.3.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/smtp-server/node_modules/nodemailer": { + "version": "6.9.13", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.13.tgz", + "integrity": "sha512-7o38Yogx6krdoBf3jCAqnIN4oSQFx+fMa0I7dK1D+me9kBxx12D+/33wSb+fhOCtIxvYJ+4x4IMEhmhCKfAiOA==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/socket.io": { + "version": "4.7.5", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.7.5.tgz", + "integrity": "sha512-DmeAkF6cwM9jSfmp6Dr/5/mfMwb5Z5qRrSXLpo3Fq5SqyU8CMF15jIN4ZhfSwu35ksM1qmHZDQ/DK5XTccSTvA==", + "dev": true, + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.3.2", + "engine.io": "~6.5.2", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz", + "integrity": "sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==", + "dev": true, + "dependencies": { + "debug": "~4.3.4", + "ws": "~8.17.1" + } + }, + "node_modules/socket.io-adapter/node_modules/debug": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", + "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-adapter/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/socket.io-adapter/node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "dev": true, + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser/node_modules/debug": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", + "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-parser/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/socket.io/node_modules/debug": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", + "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sqlstring": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", + "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true + }, + "node_modules/tlds": { + "version": "1.252.0", + "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.252.0.tgz", + "integrity": "sha512-GA16+8HXvqtfEnw/DTcwB0UU354QE1n3+wh08oFjr6Znl7ZLAeUgYzCcK+/CCrOyE0vnHR8/pu3XXG3vDijXpQ==", + "dev": true, + "bin": { + "tlds": "bin.js" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dev": true, + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.0.0.tgz", + "integrity": "sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==", + "dev": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dev": true, + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "dev": true + }, + "node_modules/undici-types": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.13.0.tgz", + "integrity": "sha512-xtFJHudx8S2DSoujjMd1WeWvn7KKWFRESZTMeL1RptAYERu29D6jphMjjY+vn96jvN3kVPDNxU/E13VTaXj6jg==", + "dev": true + }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "dev": true, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dev": true, + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.0.0.tgz", + "integrity": "sha512-1lfMEm2IEr7RIV+f4lUNPOqfFL+pO+Xw3fJSqmjX9AbXcXcYOkCe1P6+9VBZB6n94af16NfZf+sSk0JCBZC9aw==", + "dev": true, + "dependencies": { + "tr46": "^5.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/wildstring": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/wildstring/-/wildstring-1.0.9.tgz", + "integrity": "sha512-XBNxKIMLO6uVHf1Xvo++HGWAZZoiVCHmEMCmZJzJ82vQsuUJCLw13Gzq0mRCATk7a3+ZcgeOKSDioavuYqtlfA==", + "dev": true + }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/playwright/package.json b/playwright/package.json new file mode 100644 index 0000000000..e1b3a884f3 --- /dev/null +++ b/playwright/package.json @@ -0,0 +1,21 @@ +{ + "name": "scenarios", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": {}, + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "@playwright/test": "^1.45.1", + "dotenv": "^16.4.5", + "dotenv-expand": "^11.0.6", + "maildev": "github:timshel/maildev#3.0.0-rc1" + }, + "dependencies": { + "mysql2": "^3.10.2", + "otpauth": "^9.3.2", + "pg": "^8.12.0" + } +} diff --git a/playwright/playwright.config.ts b/playwright/playwright.config.ts new file mode 100644 index 0000000000..ea3f35c7b8 --- /dev/null +++ b/playwright/playwright.config.ts @@ -0,0 +1,132 @@ +import { defineConfig, devices } from '@playwright/test'; +import { exec } from 'node:child_process'; + +const utils = require('./global-utils'); + +utils.loadEnv(); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './.', + /* Run tests in files in parallel */ + fullyParallel: false, + + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + workers: 1, + + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + timeout: 20 * 1000, + expect: { timeout: 10 * 1000 }, + + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: process.env.DOMAIN, + browserName: 'firefox', + locale: 'en-GB', + timezoneId: 'Europe/London', + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'mariadb-setup', + testMatch: 'tests/setups/db-setup.ts', + use: { serviceName: "Mariadb" }, + teardown: 'mariadb-teardown', + }, + { + name: 'mysql-setup', + testMatch: 'tests/setups/db-setup.ts', + use: { serviceName: "Mysql" }, + teardown: 'mysql-teardown', + }, + { + name: 'postgres-setup', + testMatch: 'tests/setups/db-setup.ts', + use: { serviceName: "Postgres" }, + teardown: 'postgres-teardown', + }, + { + name: 'sso-setup', + testMatch: 'tests/setups/sso-setup.ts', + teardown: 'sso-teardown', + }, + + { + name: 'mariadb', + testMatch: 'tests/*.spec.ts', + testIgnore: 'tests/sso_*.spec.ts', + dependencies: ['mariadb-setup'], + }, + { + name: 'mysql', + testMatch: 'tests/*.spec.ts', + testIgnore: 'tests/sso_*.spec.ts', + dependencies: ['mysql-setup'], + }, + { + name: 'postgres', + testMatch: 'tests/*.spec.ts', + testIgnore: 'tests/sso_*.spec.ts', + dependencies: ['postgres-setup'], + }, + { + name: 'sqlite', + testMatch: 'tests/*.spec.ts', + testIgnore: 'tests/sso_*.spec.ts', + }, + + { + name: 'sso-mariadb', + testMatch: 'tests/sso_*.spec.ts', + dependencies: ['sso-setup', 'mariadb-setup'], + }, + { + name: 'sso-mysql', + testMatch: 'tests/sso_*.spec.ts', + dependencies: ['sso-setup', 'mysql-setup'], + }, + { + name: 'sso-postgres', + testMatch: 'tests/sso_*.spec.ts', + dependencies: ['sso-setup', 'postgres-setup'], + }, + { + name: 'sso-sqlite', + testMatch: 'tests/sso_*.spec.ts', + dependencies: ['sso-setup'], + }, + + { + name: 'mariadb-teardown', + testMatch: 'tests/setups/db-teardown.ts', + use: { serviceName: "Mariadb" }, + }, + { + name: 'mysql-teardown', + testMatch: 'tests/setups/db-teardown.ts', + use: { serviceName: "Mysql" }, + }, + { + name: 'postgres-teardown', + testMatch: 'tests/setups/db-teardown.ts', + use: { serviceName: "Postgres" }, + }, + { + name: 'sso-teardown', + testMatch: 'tests/setups/sso-teardown.ts', + }, + ], + + globalSetup: require.resolve('./global-setup'), +}); diff --git a/playwright/test.env b/playwright/test.env new file mode 100644 index 0000000000..c67aa82eb9 --- /dev/null +++ b/playwright/test.env @@ -0,0 +1,90 @@ +################################################################## +### Shared Playwright conf test file Vaultwarden and Databases ### +################################################################## + +ENV=test +DC_ENV_FILE=test.env +COMPOSE_IGNORE_ORPHANS=True +DOCKER_BUILDKIT=1 + +##################### +# Playwright Config # +##################### +PW_KEEP_SERVICE_RUNNNING=${PW_KEEP_SERVICE_RUNNNING:-false} +VAULTWARDEN_SMTP_FROM=vaultwarden@playwright.test + +##################### +# Maildev Config # +##################### +MAILDEV_HTTP_PORT=1081 +MAILDEV_SMTP_PORT=1026 +MAILDEV_HOST=127.0.0.1 + +################ +# Users Config # +################ +TEST_USER=test +TEST_USER_PASSWORD=Master Password +TEST_USER_MAIL=${TEST_USER}@example.com + +TEST_USER2=test2 +TEST_USER2_PASSWORD=Master Password +TEST_USER2_MAIL=${TEST_USER2}@example.com + +TEST_USER3=test3 +TEST_USER3_PASSWORD=Master Password +TEST_USER3_MAIL=${TEST_USER3}@example.com + +################### +# Keycloak Config # +################### +KEYCLOAK_ADMIN=admin +KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN} +KC_HTTP_HOST=127.0.0.1 +KC_HTTP_PORT=8081 + +# Script parameters (use Keycloak and VaultWarden config too) +TEST_REALM=test +DUMMY_REALM=dummy +DUMMY_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${DUMMY_REALM} + +###################### +# Vaultwarden Config # +###################### +ROCKET_PORT=8003 +DOMAIN=http://127.0.0.1:${ROCKET_PORT} +SMTP_SECURITY=off +SMTP_PORT=${MAILDEV_SMTP_PORT} +SMTP_FROM_NAME=Vaultwarden +SMTP_TIMEOUT=5 + +SSO_CLIENT_ID=VaultWarden +SSO_CLIENT_SECRET=VaultWarden +SSO_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${TEST_REALM} +SSO_PKCE=true + +########################### +# Docker MariaDb container# +########################### +MARIADB_PORT=3307 +MARIADB_ROOT_PASSWORD=vaultwarden +MARIADB_USER=vaultwarden +MARIADB_PASSWORD=vaultwarden +MARIADB_DATABASE=vaultwarden + +########################### +# Docker Mysql container# +########################### +MYSQL_PORT=3309 +MYSQL_ROOT_PASSWORD=vaultwarden +MYSQL_USER=vaultwarden +MYSQL_PASSWORD=vaultwarden +MYSQL_DATABASE=vaultwarden + +############################ +# Docker Postgres container# +############################ +POSTGRES_PORT=5433 +POSTGRES_USER=vaultwarden +POSTGRES_PASSWORD=vaultwarden +POSTGRES_DB=vaultwarden diff --git a/playwright/tests/login.smtp.spec.ts b/playwright/tests/login.smtp.spec.ts new file mode 100644 index 0000000000..010b80958a --- /dev/null +++ b/playwright/tests/login.smtp.spec.ts @@ -0,0 +1,159 @@ +import { test, expect, type TestInfo } from '@playwright/test'; +import { MailDev } from 'maildev'; + +const utils = require('../global-utils'); +import { createAccount, logUser } from './setups/user'; + +let users = utils.loadEnv(); + +let mailserver; + +test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { + mailserver = new MailDev({ + port: process.env.MAILDEV_SMTP_PORT, + web: { port: process.env.MAILDEV_HTTP_PORT }, + }) + + await mailserver.listen(); + + await utils.startVaultwarden(browser, testInfo, { + SMTP_HOST: process.env.MAILDEV_HOST, + SMTP_FROM: process.env.VAULTWARDEN_SMTP_FROM, + }); +}); + +test.afterAll('Teardown', async ({}) => { + utils.stopVaultwarden(); + if( mailserver ){ + await mailserver.close(); + } +}); + +test('Account creation', async ({ page }) => { + const emails = mailserver.iterator(users.user1.email); + + await createAccount(test, page, users.user1); + + const { value: created } = await emails.next(); + expect(created.subject).toBe("Welcome"); + expect(created.from[0]?.address).toBe(process.env.VAULTWARDEN_SMTP_FROM); + + // Back to the login page + await expect(page).toHaveTitle('Vaultwarden Web'); + await expect(page.getByTestId("toast-message")).toHaveText(/Your new account has been created/); + await page.getByRole('button', { name: 'Continue' }).click(); + + // Unlock page + await page.getByLabel('Master password').fill(users.user1.password); + await page.getByRole('button', { name: 'Log in with master password' }).click(); + + // We are now in the default vault page + await expect(page).toHaveTitle(/Vaults/); + + const { value: logged } = await emails.next(); + expect(logged.subject).toBe("New Device Logged In From Firefox"); + expect(logged.to[0]?.address).toBe(process.env.TEST_USER_MAIL); + expect(logged.from[0]?.address).toBe(process.env.VAULTWARDEN_SMTP_FROM); + + emails.return(); +}); + +test('Login', async ({ context, page }) => { + const emails = mailserver.iterator(users.user1.email); + + await logUser(test, page, users.user1); + + await test.step('new device email', async () => { + const { value: logged } = await emails.next(); + expect(logged.subject).toBe("New Device Logged In From Firefox"); + expect(logged.from[0]?.address).toBe(process.env.VAULTWARDEN_SMTP_FROM); + }); + + await test.step('verify email', async () => { + await page.getByText('Verify your account\'s email').click(); + await expect(page.getByTestId("toast-message")).toHaveText(/Check your email inbox for a verification link/); + await page.locator('#toast-container').getByRole('button').click(); + await expect(page.getByTestId("toast-message")).toHaveCount(0); + + const { value: verify } = await emails.next(); + expect(verify.subject).toBe("Verify Your Email"); + expect(verify.from[0]?.address).toBe(process.env.VAULTWARDEN_SMTP_FROM); + + const page2 = await context.newPage(); + await page2.setContent(verify.html); + const link = await page2.getByTestId("verify").getAttribute("href"); + await page2.close(); + + await page.goto(link); + await expect(page.getByTestId("toast-message")).toHaveText("Account email verified"); + }); + + emails.return(); +}); + +test('Activaite 2fa', async ({ context, page }) => { + const emails = mailserver.buffer(users.user1.email); + + await logUser(test, page, users.user1); + + await page.getByRole('button', { name: users.user1.name }).click(); + await page.getByRole('menuitem', { name: 'Account settings' }).click(); + await page.getByLabel('Security').click(); + await page.getByRole('link', { name: 'Two-step login' }).click(); + await page.locator('li').filter({ hasText: 'Email Verification codes will' }).getByRole('button').click(); + await page.getByLabel('Master password (required)').fill(users.user1.password); + await page.getByRole('button', { name: 'Continue' }).click(); + await page.getByRole('button', { name: 'Send email' }).click(); + + const codeMail = await emails.next((mail) => mail.subject === "Vaultwarden Login Verification Code"); + const page2 = await context.newPage(); + await page2.setContent(codeMail.html); + const code = await page2.getByTestId("2fa").innerText(); + await page2.close(); + + await page.getByLabel('2. Enter the resulting 6').fill(code); + await page.getByRole('button', { name: 'Turn on' }).click(); + await page.getByRole('heading', { name: 'Turned on', exact: true }); + + emails.close(); +}); + +test('2fa', async ({ context, page }) => { + const emails = mailserver.buffer(users.user1.email); + + await test.step('login', async () => { + await page.goto('/'); + + await page.getByLabel(/Email address/).fill(users.user1.email); + await page.getByRole('button', { name: 'Continue' }).click(); + await page.getByLabel('Master password').fill(users.user1.password); + await page.getByRole('button', { name: 'Log in with master password' }).click(); + + const codeMail = await emails.next((mail) => mail.subject === "Vaultwarden Login Verification Code"); + const page2 = await context.newPage(); + await page2.setContent(codeMail.html); + const code = await page2.getByTestId("2fa").innerText(); + await page2.close(); + + await page.getByLabel('Verification code').fill(code); + await page.getByRole('button', { name: 'Continue' }).click(); + + await expect(page).toHaveTitle(/Vaults/); + }) + + await test.step('disable', async () => { + await page.getByRole('button', { name: 'Test' }).click(); + await page.getByRole('menuitem', { name: 'Account settings' }).click(); + await page.getByLabel('Security').click(); + await page.getByRole('link', { name: 'Two-step login' }).click(); + await page.locator('li').filter({ hasText: 'Email Turned on Verification' }).getByRole('button').click(); + await page.getByLabel('Master password (required)').click(); + await page.getByLabel('Master password (required)').fill(users.user1.password); + await page.getByRole('button', { name: 'Continue' }).click(); + await page.getByRole('button', { name: 'Turn off' }).click(); + await page.getByRole('button', { name: 'Yes' }).click(); + await expect(page.getByTestId("toast-message")).toHaveText(/Two-step login provider turned off/); + }); + + emails.close(); +}); diff --git a/playwright/tests/login.spec.ts b/playwright/tests/login.spec.ts new file mode 100644 index 0000000000..b8cfbc1a5e --- /dev/null +++ b/playwright/tests/login.spec.ts @@ -0,0 +1,97 @@ +import { test, expect, type Page, type TestInfo } from '@playwright/test'; +import * as OTPAuth from "otpauth"; + +import * as utils from "../global-utils"; +import { createAccount, logUser } from './setups/user'; + +let users = utils.loadEnv(); +let totp; + +test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { + await utils.startVaultwarden(browser, testInfo, {}); +}); + +test.afterAll('Teardown', async ({}, testInfo: TestInfo) => { + utils.stopVaultwarden(testInfo); +}); + +test('Account creation', async ({ page }) => { + // Landing page + await createAccount(test, page, users.user1); + + await page.getByRole('button', { name: 'Continue' }).click(); + + // Unlock page + await page.getByLabel('Master password').fill(users.user1.password); + await page.getByRole('button', { name: 'Log in with master password' }).click(); + + // We are now in the default vault page + await expect(page).toHaveTitle(/Vaults/); +}); + +test('Master password login', async ({ page }) => { + await logUser(test, page, users.user1); +}); + +test('Authenticator 2fa', async ({ context, page }) => { + let totp; + + await test.step('Login', async () => { + await logUser(test, page, users.user1); + }); + + await test.step('Activate', async () => { + await page.getByRole('button', { name: users.user1.name }).click(); + await page.getByRole('menuitem', { name: 'Account settings' }).click(); + await page.getByLabel('Security').click(); + await page.getByRole('link', { name: 'Two-step login' }).click(); + await page.locator('li').filter({ hasText: 'Authenticator app Use an' }).getByRole('button').click(); + await page.getByLabel('Master password (required)').fill(users.user1.password); + await page.getByRole('button', { name: 'Continue' }).click(); + + const secret = await page.getByLabel('Key').innerText(); + totp = new OTPAuth.TOTP({ secret, period: 30 }); + + await page.getByLabel('3. Enter the resulting 6').fill(totp.generate()); + await page.getByRole('button', { name: 'Turn on' }).click(); + await page.getByRole('heading', { name: 'Turned on', exact: true }); + await page.getByLabel('Close').click(); + }) + + await test.step('logout', async () => { + await page.getByRole('button', { name: users.user1.name }).click(); + await page.getByRole('menuitem', { name: 'Log out' }).click(); + await expect(page.getByTestId("toast-title")).toHaveText("Logged out"); + await page.locator('#toast-container').getByRole('button').click(); + await expect(page.getByTestId("toast-title")).toHaveCount(0); + }); + + await test.step('login', async () => { + let timestamp = Date.now(); // Need to use the next token + timestamp = timestamp + (totp.period - (Math.floor(timestamp / 1000) % totp.period) + 1) * 1000; + + await page.getByLabel(/Email address/).fill(users.user1.email); + await page.getByRole('button', { name: 'Continue' }).click(); + await page.getByLabel('Master password').fill(users.user1.password); + await page.getByRole('button', { name: 'Log in with master password' }).click(); + + await page.getByLabel('Verification code').fill(totp.generate({timestamp})); + await page.getByRole('button', { name: 'Continue' }).click(); + + await expect(page).toHaveTitle(/Vaults/); + }); + + await test.step('disable', async () => { + await page.getByRole('button', { name: 'Test' }).click(); + await page.getByRole('menuitem', { name: 'Account settings' }).click(); + await page.getByLabel('Security').click(); + await page.getByRole('link', { name: 'Two-step login' }).click(); + await page.locator('li').filter({ hasText: /Authenticator app/ }).getByRole('button').click(); + await page.getByLabel('Master password (required)').click(); + await page.getByLabel('Master password (required)').fill(users.user1.password); + await page.getByRole('button', { name: 'Continue' }).click(); + await page.getByRole('button', { name: 'Turn off' }).click(); + await page.getByRole('button', { name: 'Yes' }).click(); + await expect(page.getByTestId("toast-message")).toHaveText(/Two-step login provider turned off/); + }); +}); diff --git a/playwright/tests/organization.spec.ts b/playwright/tests/organization.spec.ts new file mode 100644 index 0000000000..035d3bf7fb --- /dev/null +++ b/playwright/tests/organization.spec.ts @@ -0,0 +1,159 @@ +import { test, expect, type TestInfo } from '@playwright/test'; +import { MailDev } from 'maildev'; + +import * as utils from "../global-utils"; +import { createAccount, logUser } from './setups/user'; + +let users = utils.loadEnv(); + +let mailserver, user1Mails, user2Mails, user3Mails; + +test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { + mailserver = new MailDev({ + port: process.env.MAILDEV_SMTP_PORT, + web: { port: process.env.MAILDEV_HTTP_PORT }, + }) + + await mailserver.listen(); + + await utils.startVaultwarden(browser, testInfo, { + SMTP_HOST: process.env.MAILDEV_HOST, + SMTP_FROM: process.env.VAULTWARDEN_SMTP_FROM, + }); + + user1Mails = mailserver.iterator(users.user1.email); + user2Mails = mailserver.iterator(users.user2.email); + user3Mails = mailserver.iterator(users.user3.email); +}); + +test.afterAll('Teardown', async ({}, testInfo: TestInfo) => { + utils.stopVaultwarden(testInfo); + utils.closeMails(mailserver, [user1Mails, user2Mails, user3Mails]); +}); + +test('Create user3', async ({ page }) => { + await createAccount(test, page, users.user3, user3Mails); +}); + +test('Invite users', async ({ page }) => { + await createAccount(test, page, users.user1, user1Mails); + await logUser(test, page, users.user1, user1Mails); + + await test.step('Create Org', async () => { + await page.getByRole('link', { name: 'New organisation' }).click(); + await page.getByLabel('Organisation name (required)').fill('Test'); + await page.getByRole('button', { name: 'Submit' }).click(); + await page.locator('div').filter({ hasText: 'Members' }).nth(2).click(); + }); + + await test.step('Invite user2', async () => { + await page.getByRole('button', { name: 'Invite member' }).click(); + await page.getByLabel('Email (required)').fill(users.user2.email); + await page.getByRole('tab', { name: 'Collections' }).click(); + await page.locator('label').filter({ hasText: 'Grant access to all current' }).click(); + await page.getByRole('button', { name: 'Save' }).click(); + await expect(page.getByTestId("toast-message")).toHaveText('User(s) invited'); + }); + + await test.step('Invite user3', async () => { + await page.getByRole('button', { name: 'Invite member' }).click(); + await page.getByLabel('Email (required)').fill(users.user3.email); + await page.getByRole('tab', { name: 'Collections' }).click(); + await page.locator('label').filter({ hasText: 'Grant access to all current' }).click(); + await page.getByRole('button', { name: 'Save' }).click(); + await expect(page.getByTestId("toast-message")).toHaveText('User(s) invited'); + }); +}); + +test('invited with new account', async ({ page }) => { + const { value: invited } = await user2Mails.next(); + expect(invited.subject).toContain("Join Test") + + await test.step('Create account', async () => { + await page.setContent(invited.html); + const link = await page.getByTestId("invite").getAttribute("href"); + await page.goto(link); + await expect(page).toHaveTitle(/Create account | Vaultwarden Web/); + + await page.getByLabel('Name').fill(users.user2.name); + await page.getByLabel('Master password\n (required)', { exact: true }).fill(users.user2.password); + await page.getByLabel('Re-type master password').fill(users.user2.password); + await page.getByRole('button', { name: 'Create account' }).click(); + + // Back to the login page + await expect(page).toHaveTitle('Vaultwarden Web'); + await expect(page.getByTestId("toast-message")).toHaveText(/Your new account has been created/); + + const { value: welcome } = await user2Mails.next(); + expect(welcome.subject).toContain("Welcome") + }); + + await test.step('Login', async () => { + await page.getByLabel(/Email address/).fill(users.user2.email); + await page.getByRole('button', { name: 'Continue' }).click(); + + // Unlock page + await page.getByLabel('Master password').fill(users.user2.password); + await page.getByRole('button', { name: 'Log in with master password' }).click(); + + // We are now in the default vault page + await expect(page).toHaveTitle(/Vaults/); + await expect(page.getByTestId("toast-title")).toHaveText("Invitation accepted"); + + const { value: logged } = await user2Mails.next(); + expect(logged.subject).toContain("New Device Logged"); + }); + + const { value: accepted } = await user1Mails.next(); + expect(accepted.subject).toContain("Invitation to Test accepted") +}); + +test('invited with existing account', async ({ page }) => { + const { value: invited } = await user3Mails.next(); + expect(invited.subject).toContain("Join Test") + + await page.setContent(invited.html); + const link = await page.getByTestId("invite").getAttribute("href"); + + await page.goto(link); + + // We should be on login page with email prefilled + await expect(page).toHaveTitle(/Vaultwarden Web/); + await page.getByRole('button', { name: 'Continue' }).click(); + + // Unlock page + await page.getByLabel('Master password').fill(users.user3.password); + await page.getByRole('button', { name: 'Log in with master password' }).click(); + + // We are now in the default vault page + await expect(page).toHaveTitle(/Vaults/); + await expect(page.getByTestId("toast-title")).toHaveText("Invitation accepted"); + + const { value: logged } = await user3Mails.next(); + expect(logged.subject).toContain("New Device Logged") + + const { value: accepted } = await user1Mails.next(); + expect(accepted.subject).toContain("Invitation to Test accepted") +}); + +test('Confirm invited user', async ({ page }) => { + await logUser(test, page, users.user1, user1Mails); + await page.getByLabel('Switch products').click(); + await page.getByRole('link', { name: ' Admin Console' }).click(); + await page.getByLabel('Members').click(); + + await test.step('Accept user2', async () => { + await page.getByRole('row', { name: users.user2.name }).getByLabel('Options').click(); + await page.getByRole('menuitem', { name: 'Confirm' }).click(); + await page.getByRole('button', { name: 'Confirm' }).click(); + await expect(page.getByTestId("toast-message")).toHaveText(/confirmed/); + + const { value: logged } = await user2Mails.next(); + expect(logged.subject).toContain("Invitation to Test confirmed"); + }); +}); + +test('Organization is visible', async ({ page }) => { + await logUser(test, page, users.user2, user2Mails); + await page.getByLabel('vault: Test').click(); +}); diff --git a/playwright/tests/setups/db-setup.ts b/playwright/tests/setups/db-setup.ts new file mode 100644 index 0000000000..eb37fdc102 --- /dev/null +++ b/playwright/tests/setups/db-setup.ts @@ -0,0 +1,7 @@ +import { test } from './db-test'; + +const utils = require('../../global-utils'); + +test('DB start', async ({ serviceName }) => { + utils.startComposeService(serviceName); +}); diff --git a/playwright/tests/setups/db-teardown.ts b/playwright/tests/setups/db-teardown.ts new file mode 100644 index 0000000000..5f753a9d2c --- /dev/null +++ b/playwright/tests/setups/db-teardown.ts @@ -0,0 +1,11 @@ +import { test } from './db-test'; + +const utils = require('../../global-utils'); + +utils.loadEnv(); + +test('DB teardown ?', async ({ serviceName }) => { + if( process.env.PW_KEEP_SERVICE_RUNNNING !== "true" ) { + utils.stopComposeService(serviceName); + } +}); diff --git a/playwright/tests/setups/db-test.ts b/playwright/tests/setups/db-test.ts new file mode 100644 index 0000000000..4a72d37c57 --- /dev/null +++ b/playwright/tests/setups/db-test.ts @@ -0,0 +1,9 @@ +import { test as base } from '@playwright/test'; + +export type TestOptions = { + serviceName: string; +}; + +export const test = base.extend({ + serviceName: ['', { option: true }], +}); diff --git a/playwright/tests/setups/sso-setup.ts b/playwright/tests/setups/sso-setup.ts new file mode 100644 index 0000000000..0d25140ef6 --- /dev/null +++ b/playwright/tests/setups/sso-setup.ts @@ -0,0 +1,19 @@ +import { test, expect, type TestInfo } from '@playwright/test'; + +const { exec } = require('node:child_process'); +const utils = require('../../global-utils'); + +utils.loadEnv(); + +test.beforeAll('Setup', async () => { + console.log("Starting Keycloak"); + exec(`docker compose --profile keycloak --env-file test.env up`); +}); + +test('Keycloak is up', async ({ page }) => { + test.setTimeout(60000); + await utils.waitFor(process.env.SSO_AUTHORITY, page.context().browser()); + // Dummy authority is created at the end of the setup + await utils.waitFor(process.env.DUMMY_AUTHORITY, page.context().browser()); + console.log(`Keycloak running on: ${process.env.SSO_AUTHORITY}`); +}); diff --git a/playwright/tests/setups/sso-teardown.ts b/playwright/tests/setups/sso-teardown.ts new file mode 100644 index 0000000000..2899afff14 --- /dev/null +++ b/playwright/tests/setups/sso-teardown.ts @@ -0,0 +1,15 @@ +import { test, type FullConfig } from '@playwright/test'; + +const { execSync } = require('node:child_process'); +const utils = require('../../global-utils'); + +utils.loadEnv(); + +test('Keycloak teardown', async () => { + if( process.env.PW_KEEP_SERVICE_RUNNNING === "true" ) { + console.log("Keep Keycloak running"); + } else { + console.log("Keycloak stopping"); + execSync(`docker compose --profile keycloak --env-file test.env stop Keycloak`); + } +}); diff --git a/playwright/tests/setups/sso.ts b/playwright/tests/setups/sso.ts new file mode 100644 index 0000000000..90c1562691 --- /dev/null +++ b/playwright/tests/setups/sso.ts @@ -0,0 +1,82 @@ +import { expect, type Page, Test } from '@playwright/test'; +import { type Mail } from 'maildev'; + +export async function createAccount(test: Test, page: Page, user: { email: string, name: string, password: string }, emails: AsyncIterator) { + await test.step('Create user', async () => { + await test.step('Landing page', async () => { + await page.goto('/'); + await page.getByLabel(/Email address/).fill(user.email); + await page.getByRole('button', { name: 'Continue' }).click(); + }); + + await test.step('SSo start page', async () => { + await page.getByRole('link', { name: /Enterprise single sign-on/ }).click(); + }); + + await test.step('Keycloak login', async () => { + await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible(); + await page.getByLabel(/Username/).fill(user.name); + await page.getByLabel('Password', { exact: true }).fill(user.password); + await page.getByRole('button', { name: 'Sign In' }).click(); + }); + + await test.step('Create Vault account', async () => { + await expect(page.getByText('Set master password')).toBeVisible(); + await page.getByLabel('Master password', { exact: true }).fill(user.password); + await page.getByLabel('Re-type master password').fill(user.password); + await page.getByRole('button', { name: 'Submit' }).click(); + }); + + await test.step('Default vault page', async () => { + await expect(page).toHaveTitle(/Vaults/); + }); + + if( emails ){ + await test.step('Check emails', async () => { + const { value: logged } = await emails.next(); + expect(logged.subject).toContain("New Device Logged"); + + const { value: password } = await emails.next(); + expect(password.subject).toContain("Master Password Has Been Changed"); + }); + } + }); +} + +export async function logUser(test: Test, page: Page, user: { email: string, password: string }, emails: AsyncIterator) { + await test.step('Log user', async () => { + await test.step('Landing page', async () => { + await page.goto('/'); + await page.getByLabel(/Email address/).fill(user.email); + await page.getByRole('button', { name: 'Continue' }).click(); + }); + + await test.step('SSo start page', async () => { + await page.getByRole('link', { name: /Enterprise single sign-on/ }).click(); + }); + + await test.step('Keycloak login', async () => { + await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible(); + await page.getByLabel(/Username/).fill(user.name); + await page.getByLabel('Password', { exact: true }).fill(user.password); + await page.getByRole('button', { name: 'Sign In' }).click(); + }); + + await test.step('Unlock vault', async () => { + await expect(page).toHaveTitle('Vaultwarden Web'); + await 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(/Vaults/); + }); + + if( emails ){ + await test.step('Check email', async () => { + const { value: logged } = await emails.next(); + expect(logged.subject).toContain("New Device Logged"); + }); + } + }); +} diff --git a/playwright/tests/setups/user.ts b/playwright/tests/setups/user.ts new file mode 100644 index 0000000000..4c17849f56 --- /dev/null +++ b/playwright/tests/setups/user.ts @@ -0,0 +1,47 @@ +import { expect, type Browser,Page } from '@playwright/test'; + +export async function createAccount(test, page: Page, user: { email: string, name: string, password: string }, emails) { + await test.step('Create user', async () => { + // Landing page + await page.goto('/'); + await page.getByRole('link', { name: 'Create account' }).click(); + + // Back to Vault create account + await expect(page).toHaveTitle(/Create account | Vaultwarden Web/); + await page.getByLabel(/Email address/).fill(user.email); + await page.getByLabel('Name').fill(user.name); + await page.getByLabel('Master password\n (required)', { exact: true }).fill(user.password); + await page.getByLabel('Re-type master password').fill(user.password); + await page.getByRole('button', { name: 'Create account' }).click(); + + // Back to the login page + await expect(page).toHaveTitle('Vaultwarden Web'); + await expect(page.getByTestId("toast-message")).toHaveText(/Your new account has been created/); + + if( emails ){ + const { value: welcome } = await emails.next(); + expect(welcome.subject).toContain("Welcome"); + } + }); +} + +export async function logUser(test, page: Page, user: { email: string, password: string }, emails) { + await test.step('Log user', async () => { + // Landing page + await page.goto('/'); + await page.getByLabel(/Email address/).fill(user.email); + await page.getByRole('button', { name: 'Continue' }).click(); + + // Unlock page + await page.getByLabel('Master password').fill(user.password); + await page.getByRole('button', { name: 'Log in with master password' }).click(); + + // We are now in the default vault page + await expect(page).toHaveTitle(/Vaults/); + + if( emails ){ + const { value: logged } = await emails.next(); + expect(logged.subject).toContain("New Device Logged"); + } + }); +} diff --git a/playwright/tests/sso_login.spec.ts b/playwright/tests/sso_login.spec.ts new file mode 100644 index 0000000000..14f7ff351b --- /dev/null +++ b/playwright/tests/sso_login.spec.ts @@ -0,0 +1,74 @@ +import { test, expect, type TestInfo } from '@playwright/test'; +import { createAccount, logUser } from './setups/user'; +import * as utils from "../global-utils"; + +let users = utils.loadEnv(); + +test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { + await utils.startVaultwarden(browser, testInfo, { + SSO_ENABLED: true, + SSO_ONLY: false + }); +}); + +test.afterAll('Teardown', async ({}, testInfo: TestInfo) => { + utils.stopVaultwarden(testInfo); +}); + +test('Account creation using SSO', async ({ page }) => { + // Landing page + await createAccount(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(/Vaults/); +}); + + +test('Non SSO login Failure', async ({ page, browser }, testInfo: TestInfo) => { + await utils.restartVaultwarden(page, testInfo, { + SSO_ENABLED: true, + SSO_ONLY: true + }, false); + + // Landing page + await page.goto('/'); + await page.getByLabel(/Email address/).fill(users.user1.email); + await page.getByRole('button', { name: 'Continue' }).click(); + + // Unlock page + await page.getByLabel('Master password').fill(users.user1.password); + await page.getByRole('button', { name: 'Log in with master password' }).click(); + + // An error should appear + await page.getByLabel('SSO sign-in is required') +}); + +test('No SSO login', async ({ page }, testInfo: TestInfo) => { + await utils.restartVaultwarden(page, testInfo, { + SSO_ENABLED: false + }, false); + + // Landing page + await page.goto('/'); + await page.getByLabel(/Email address/).fill(users.user1.email); + await page.getByRole('button', { name: 'Continue' }).click(); + + // No SSO button + await page.getByLabel('Master password'); + await expect(page.getByRole('link', { name: /Enterprise single sign-on/ })).toHaveCount(0); +}); diff --git a/playwright/tests/sso_organization.spec.ts b/playwright/tests/sso_organization.spec.ts new file mode 100644 index 0000000000..720e4d5f47 --- /dev/null +++ b/playwright/tests/sso_organization.spec.ts @@ -0,0 +1,152 @@ +import { test, expect, type TestInfo } from '@playwright/test'; +import { MailDev } from 'maildev'; + +import * as utils from "../global-utils"; +import { createAccount, logUser } from './setups/sso'; + +let users = utils.loadEnv(); + +let mailserver, user1Mails, user2Mails, user3Mails; + +test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { + mailserver = new MailDev({ + port: process.env.MAILDEV_SMTP_PORT, + web: { port: process.env.MAILDEV_HTTP_PORT }, + }) + + await mailserver.listen(); + + await utils.startVaultwarden(browser, testInfo, { + SMTP_HOST: process.env.MAILDEV_HOST, + SMTP_FROM: process.env.VAULTWARDEN_SMTP_FROM, + SSO_ENABLED: true, + SSO_ONLY: true, + }); + + user1Mails = mailserver.iterator(users.user1.email); + user2Mails = mailserver.iterator(users.user2.email); + user3Mails = mailserver.iterator(users.user3.email); +}); + +test.afterAll('Teardown', async ({}, testInfo: TestInfo) => { + utils.stopVaultwarden(testInfo); + utils.closeMails(mailserver, [user1Mails, user2Mails, user3Mails]); +}); + +test('Create user2', async ({ page }) => { + await createAccount(test, page, users.user2, user2Mails); +}); + +test('Invite users', async ({ page }) => { + await createAccount(test, page, users.user1, user1Mails); + + await test.step('Create Org', async () => { + await page.getByRole('link', { name: 'New organisation' }).click(); + await page.getByLabel('Organisation name (required)').fill('Test'); + await page.getByRole('button', { name: 'Submit' }).click(); + await page.locator('div').filter({ hasText: 'Members' }).nth(2).click(); + }); + + await test.step('Invite user2', async () => { + await page.getByRole('button', { name: 'Invite member' }).click(); + await page.getByLabel('Email (required)').fill(users.user2.email); + await page.getByRole('tab', { name: 'Collections' }).click(); + await page.locator('label').filter({ hasText: 'Grant access to all current' }).click(); + await page.getByRole('button', { name: 'Save' }).click(); + await expect(page.getByTestId("toast-message")).toHaveText('User(s) invited'); + }); + + await test.step('Invite user3', async () => { + await page.getByRole('button', { name: 'Invite member' }).click(); + await page.getByLabel('Email (required)').fill(users.user3.email); + await page.getByRole('tab', { name: 'Collections' }).click(); + await page.locator('label').filter({ hasText: 'Grant access to all current' }).click(); + await page.getByRole('button', { name: 'Save' }).click(); + await expect(page.getByTestId("toast-message")).toHaveText('User(s) invited'); + }); +}); + +test('invited with existing account', async ({ page }) => { + const link = await test.step('Extract email link', async () => { + const { value: invited } = await user2Mails.next(); + expect(invited.subject).toContain("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 expect(page).toHaveTitle("Enterprise single sign-on | Vaultwarden Web"); + await page.getByRole('button', { name: 'Log in' }).click(); + }); + + 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('Unlock vault', async () => { + await expect(page).toHaveTitle('Vaultwarden Web'); + await page.getByLabel('Master password').fill(users.user2.password); + await page.getByRole('button', { name: 'Unlock' }).click(); + }); + + await test.step('Default vault page', async () => { + await expect(page).toHaveTitle(/Vaults/); + await expect(page.getByTestId("toast-title")).toHaveText("Invitation accepted"); + }); + + await test.step('Check mails', async () => { + const { value: logged } = await user2Mails.next(); + expect(logged.subject).toContain("New Device Logged") + + const { value: accepted } = await user1Mails.next(); + expect(accepted.subject).toContain("Invitation to Test accepted") + }); +}); + +test('invited with new account', async ({ page }) => { + const link = await test.step('Extract email link', async () => { + const { value: invited } = await user3Mails.next(); + expect(invited.subject).toContain("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 expect(page).toHaveTitle("Enterprise single sign-on | Vaultwarden Web"); + await page.getByRole('button', { name: 'Log in' }).click(); + }); + + 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('Create Vault account', async () => { + await expect(page.getByText('Set master password')).toBeVisible(); + await page.getByLabel('Master password', { exact: true }).fill(users.user3.password); + await page.getByLabel('Re-type master password').fill(users.user3.password); + await page.getByRole('button', { name: 'Submit' }).click(); + }); + + await test.step('Default vault page', async () => { + await expect(page).toHaveTitle(/Vaults/); + await expect(page.getByTestId("toast-title")).toHaveText("Invitation accepted"); + }); + + await test.step('Check mails', async () => { + const { value: logged } = await user3Mails.next(); + expect(logged.subject).toContain("New Device Logged") + + const { value: accepted } = await user1Mails.next(); + expect(accepted.subject).toContain("Invitation to Test accepted") + }); +}); diff --git a/src/static/templates/email/send_org_invite.html.hbs b/src/static/templates/email/send_org_invite.html.hbs index ce3a6c050a..8fc6ccf65e 100644 --- a/src/static/templates/email/send_org_invite.html.hbs +++ b/src/static/templates/email/send_org_invite.html.hbs @@ -9,7 +9,7 @@ Join {{{org_name}}} - Join Organization Now diff --git a/src/static/templates/email/twofactor_email.html.hbs b/src/static/templates/email/twofactor_email.html.hbs index 30990d9e0c..672daa3257 100644 --- a/src/static/templates/email/twofactor_email.html.hbs +++ b/src/static/templates/email/twofactor_email.html.hbs @@ -4,7 +4,7 @@ Vaultwarden Login Verification Code diff --git a/src/static/templates/email/verify_email.html.hbs b/src/static/templates/email/verify_email.html.hbs index c37cf36dad..29a1377fe2 100644 --- a/src/static/templates/email/verify_email.html.hbs +++ b/src/static/templates/email/verify_email.html.hbs @@ -9,7 +9,7 @@ Verify Your Email
- Your two-step verification code is: {{token}} + Your two-step verification code is: {{token}}
- Verify Email Address Now From e8d1add26e32b6283c4b2d151ebade4472c1c23d Mon Sep 17 00:00:00 2001 From: Timshel Date: Mon, 7 Oct 2024 14:46:04 +0200 Subject: [PATCH 05/10] Activate PKCE by default --- .env.template | 4 ++-- SSO.md | 15 ++------------- playwright/test.env | 1 - src/config.rs | 2 +- 4 files changed, 5 insertions(+), 17 deletions(-) diff --git a/.env.template b/.env.template index 770d799741..f47e8afb3b 100644 --- a/.env.template +++ b/.env.template @@ -457,8 +457,8 @@ #SSO_SCOPES="email profile" ## Additionnal authorization url parameters (ex: to obtain a `refresh_token` with Google Auth). # SSO_AUTHORIZE_EXTRA_PARAMS="access_type=offline&prompt=consent" -## Activate PKCE for the Auth Code flow. Recommended but disabled for now waiting for feedback on support. -# SSO_PKCE=false +## Activate PKCE for the Auth Code flow. +# SSO_PKCE=true ## Regex to add additionnal trusted audience to Id Token (by default only the client_id is trusted). # SSO_AUDIENCE_TRUSTED='^$' ## Set your Client ID and Client Key diff --git a/SSO.md b/SSO.md index bfcdca9e8d..1d4f2909c3 100644 --- a/SSO.md +++ b/SSO.md @@ -20,7 +20,7 @@ The following configurations are available - $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. Recommended but disabled for now waiting for feedback on support (default `false`). + - `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 @@ -97,7 +97,6 @@ Server configuration, nothing specific just set: - `SSO_AUTHORITY=https://${domain}/realms/${realm_name}` - `SSO_CLIENT_ID` - `SSO_CLIENT_SECRET` -- `SSO_PKCE=true` ### Testing @@ -150,7 +149,6 @@ Server configuration should look like: - `SSO_SCOPES="email profile offline_access"` - `SSO_CLIENT_ID` - `SSO_CLIENT_SECRET` -- `SSO_PKCE=true` ## Casdoor @@ -162,7 +160,6 @@ Then configure your server with: - `SSO_AUTHORITY=https://${provider_host}` - `SSO_CLIENT_ID` - `SSO_CLIENT_SECRET` -- `SSO_PKCE=true` ## GitLab @@ -177,7 +174,6 @@ Then configure your server with - `SSO_AUTHORITY=https://gitlab.com` - `SSO_CLIENT_ID` - `SSO_CLIENT_SECRET` -- `SSO_PKCE=true` ## Google Auth @@ -189,19 +185,12 @@ Configure your server with : - `SSO_AUTHORITY=https://accounts.google.com` - `SSO_AUTHORIZE_EXTRA_PARAMS="access_type=offline&prompt=consent"` -- `SSO_PKCE=true` - `SSO_CLIENT_ID` - `SSO_CLIENT_SECRET` ## Kanidm -Kanidm recommend always running with PKCE: - -Config will look like: - -- `SSO_PKCE=true` - -Otherwise you can disable the PKCE requirement with: `kanidm system oauth2 warning-insecure-client-disable-pkce CLIENT_NAME --name admin`. +Nothing specific should work with just `SSO_AUTHORITY`, `SSO_CLIENT_ID` and `SSO_CLIENT_SECRET`. ## Microsoft Entra ID diff --git a/playwright/test.env b/playwright/test.env index c67aa82eb9..1faefb9028 100644 --- a/playwright/test.env +++ b/playwright/test.env @@ -61,7 +61,6 @@ SMTP_TIMEOUT=5 SSO_CLIENT_ID=VaultWarden SSO_CLIENT_SECRET=VaultWarden SSO_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${TEST_REALM} -SSO_PKCE=true ########################### # Docker MariaDb container# diff --git a/src/config.rs b/src/config.rs index 10440a49f4..33884a187f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -659,7 +659,7 @@ make_config! { /// Authorization request extra parameters sso_authorize_extra_params: String, false, def, String::new(); /// Use PKCE during Authorization flow - sso_pkce: bool, false, def, false; + sso_pkce: bool, false, def, true; /// Regex for additionnal trusted Id token audience |> By default only the client_id is trsuted. sso_audience_trusted: String, false, option; /// CallBack Path |> Generated from Domain. From 79b2fedcbd4db2b3653bd388b49255cc46d7e1b8 Mon Sep 17 00:00:00 2001 From: Timshel Date: Mon, 7 Oct 2024 15:38:45 +0200 Subject: [PATCH 06/10] Ensure result order when searching for sso_user --- src/db/models/user.rs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/db/models/user.rs b/src/db/models/user.rs index c553fd0e6f..917ea61e78 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -1,6 +1,7 @@ use crate::util::{format_date, get_uuid, retry}; use chrono::{NaiveDateTime, TimeDelta, Utc}; use serde_json::Value; +use std::cmp::Ordering; use crate::crypto; use crate::CONFIG; @@ -487,7 +488,8 @@ impl SsoUser { } // Written as an union to make the query more lisible than using an `or_filter`. - // But `first()` does not appear to work with `union()` so we use `load()`. + // If there is a match on identifier and email we want the identifier match. + // We sort results in code since UNION does not garanty order and DBs order NULL differently. pub async fn find_by_identifier_or_email( identifier: &str, mail: &str, @@ -496,7 +498,7 @@ impl SsoUser { let lower_mail = mail.to_lowercase(); db_run! {conn: { - users::table + let mut res = users::table .inner_join(sso_users::table) .select(<(UserDb, Option)>::as_select()) .filter(sso_users::identifier.eq(identifier)) @@ -509,8 +511,17 @@ impl SsoUser { .load(conn) .expect("Error searching user by SSO identifier and email") .into_iter() - .next() .map(|(user, sso_user)| { (user.from_db(), sso_user.from_db()) }) + .collect::)>>(); + + res.sort_by(|(_, sso_user), _| { + match sso_user { + Some(db_sso_user) if db_sso_user.identifier == identifier => Ordering::Less, + _ => Ordering::Greater, + } + }); + + res.into_iter().next() }} } } From a07d99ec4b5916191e62d7ef9930bd3a69c665d7 Mon Sep 17 00:00:00 2001 From: Timshel Date: Mon, 7 Oct 2024 17:23:42 +0200 Subject: [PATCH 07/10] add SSO_ALLOW_UNKNOWN_EMAIL_VERIFICATION --- .env.template | 2 ++ SSO.md | 11 +++++++++++ src/api/identity.rs | 9 +++++++-- src/config.rs | 2 ++ 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/.env.template b/.env.template index f47e8afb3b..5d6063445b 100644 --- a/.env.template +++ b/.env.template @@ -449,6 +449,8 @@ # 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 diff --git a/SSO.md b/SSO.md index 1d4f2909c3..97286aa41b 100644 --- a/SSO.md +++ b/SSO.md @@ -15,6 +15,7 @@ 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 @@ -57,6 +58,16 @@ To delete the association (this has no impact on the `Vaultwarden` user): 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. diff --git a/src/api/identity.rs b/src/api/identity.rs index a4e21dec2e..e767201866 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -193,8 +193,13 @@ async fn _sso_login(data: ConnectData, user_uuid: &mut Option, conn: &mu err!("Email domain not allowed"); } - if !user_infos.email_verified.unwrap_or(true) { - err!("Email needs to be verified before you can use VaultWarden"); + 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." + ), + Some(false) => err!("You need to verify your email with your provider before you can log in"), + _ => (), } let mut user = User::new(user_infos.email, user_infos.user_name); diff --git a/src/config.rs b/src/config.rs index 33884a187f..4bc2a93770 100644 --- a/src/config.rs +++ b/src/config.rs @@ -648,6 +648,8 @@ make_config! { sso_only: bool, true, def, false; /// Allow email association |> Associate existing non-sso user based on email sso_signups_match_email: bool, true, def, true; + /// Allow unknown email verification status |> Allowing this with `SSO_SIGNUPS_MATCH_EMAIL=true` open potential account takeover. + sso_allow_unknown_email_verification: bool, false, def, false; /// Client ID sso_client_id: String, false, def, String::new(); /// Client Key From 982a6a1c5c78cd8509672e024a60b3fb8e920d41 Mon Sep 17 00:00:00 2001 From: Timshel Date: Mon, 18 Nov 2024 15:18:55 +0100 Subject: [PATCH 08/10] Toggle SSO button in scss --- docker/start.sh | 7 ------- playwright/tests/sso_login.spec.ts | 11 +++-------- src/api/web.rs | 14 ++++++++------ .../templates/scss/vaultwarden.scss.hbs | 19 ++++++++++++++----- 4 files changed, 25 insertions(+), 26 deletions(-) diff --git a/docker/start.sh b/docker/start.sh index 1f50883dba..4fac45142f 100755 --- a/docker/start.sh +++ b/docker/start.sh @@ -26,11 +26,4 @@ elif [ -d /etc/bitwarden_rs.d ]; then done fi -# Toggle the SSO Link -if [ "$SSO_ENABLED" = "true" ]; then - sed -i 's#a\[routerlink="/sso"\]#a\[routerlink="/sso-sed"\]#' /web-vault/app/main.*.css -else - sed -i 's#a\[routerlink="/sso-sed"\]#a\[routerlink="/sso"\]#' /web-vault/app/main.*.css -fi - exec /vaultwarden "${@}" diff --git a/playwright/tests/sso_login.spec.ts b/playwright/tests/sso_login.spec.ts index 14f7ff351b..3c3e99f928 100644 --- a/playwright/tests/sso_login.spec.ts +++ b/playwright/tests/sso_login.spec.ts @@ -38,8 +38,7 @@ test('Non SSO login', async ({ page }) => { await expect(page).toHaveTitle(/Vaults/); }); - -test('Non SSO login Failure', async ({ page, browser }, testInfo: TestInfo) => { +test('Non SSO login impossible', async ({ page, browser }, testInfo: TestInfo) => { await utils.restartVaultwarden(page, testInfo, { SSO_ENABLED: true, SSO_ONLY: true @@ -50,12 +49,8 @@ test('Non SSO login Failure', async ({ page, browser }, testInfo: TestInfo) => { 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') + // No Master password + await expect(page.getByLabel('Master password')).toBeHidden(); }); test('No SSO login', async ({ page }, testInfo: TestInfo) => { diff --git a/src/api/web.rs b/src/api/web.rs index a96d7e2a16..21e0ea5a67 100644 --- a/src/api/web.rs +++ b/src/api/web.rs @@ -89,14 +89,16 @@ fn vaultwarden_css() -> Cached> { }); let css_options = json!({ - "web_vault_version": *WEB_VAULT_VERSION, - "vw_version": *VW_VERSION, - "signup_disabled": !CONFIG.signups_allowed() && CONFIG.signups_domains_whitelist().is_empty(), - "mail_enabled": CONFIG.mail_enabled(), - "yubico_enabled": CONFIG._enable_yubico() && (CONFIG.yubico_client_id().is_some() == CONFIG.yubico_secret_key().is_some()), "emergency_access_allowed": CONFIG.emergency_access_allowed(), - "sends_allowed": CONFIG.sends_allowed(), "load_user_scss": true, + "mail_enabled": CONFIG.mail_enabled(), + "sends_allowed": CONFIG.sends_allowed(), + "signup_disabled": !CONFIG.signups_allowed() && CONFIG.signups_domains_whitelist().is_empty(), + "sso_disabled": !CONFIG.sso_enabled(), + "sso_only": CONFIG.sso_enabled() && CONFIG.sso_only(), + "vw_version": *VW_VERSION, + "web_vault_version": *WEB_VAULT_VERSION, + "yubico_enabled": CONFIG._enable_yubico() && (CONFIG.yubico_client_id().is_some() == CONFIG.yubico_secret_key().is_some()), }); let scss = match CONFIG.render_template("scss/vaultwarden.scss", &css_options) { diff --git a/src/static/templates/scss/vaultwarden.scss.hbs b/src/static/templates/scss/vaultwarden.scss.hbs index 3fc3e70ed2..b3c41fab81 100644 --- a/src/static/templates/scss/vaultwarden.scss.hbs +++ b/src/static/templates/scss/vaultwarden.scss.hbs @@ -20,11 +20,6 @@ a[href$="/settings/sponsored-families"] { @extend %vw-hide; } -/* Hide the `Enterprise Single Sign-On` button on the login page */ -a[routerlink="/sso"] { - @extend %vw-hide; -} - /* Hide Two-Factor menu in Organization settings */ bit-nav-item[route="settings/two-factor"], a[href$="/settings/two-factor"] { @@ -71,6 +66,20 @@ app-frontend-layout > app-login > form > div > div > div > p { } {{/if}} +{{#if sso_only}} +/* Hide Master password login */ +app-login form > div > div > div > div > div:has(button) { + @extend %vw-hide; +} +{{/if}} + +{{#if sso_disabled}} +/* Hide the `Enterprise Single Sign-On` button on the login page */ +a[routerlink="/sso"] { + @extend %vw-hide; +} +{{/if}} + /* Hide `Email` 2FA if mail is not enabled */ {{#unless mail_enabled}} app-two-factor-setup ul.list-group.list-group-2fa li.list-group-item:nth-child(5) { From 96fd1897bbd23583e58df7c758e09e5343f33b45 Mon Sep 17 00:00:00 2001 From: Timshel Date: Thu, 28 Nov 2024 13:59:24 +0100 Subject: [PATCH 09/10] Base64 encode state before sending it to providers --- src/api/identity.rs | 28 ++++++++++++++++++++-------- src/sso.rs | 12 +++++++----- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/src/api/identity.rs b/src/api/identity.rs index e767201866..78c814f54b 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -937,10 +937,10 @@ fn prevalidate() -> JsonResult { #[get("/connect/oidc-signin?&", rank = 1)] async fn oidcsignin(code: String, state: String, conn: DbConn) -> ApiResult { oidcsignin_redirect( - state.clone(), - sso::OIDCCodeWrapper::Ok { + state, + |decoded_state| sso::OIDCCodeWrapper::Ok { + state: decoded_state, code, - state, }, &conn, ) @@ -957,9 +957,9 @@ async fn oidcsignin_error( conn: DbConn, ) -> ApiResult { oidcsignin_redirect( - state.clone(), - sso::OIDCCodeWrapper::Error { - state, + state, + |decoded_state| sso::OIDCCodeWrapper::Error { + state: decoded_state, error, error_description, }, @@ -968,9 +968,21 @@ async fn oidcsignin_error( .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(state: String, wrapper: sso::OIDCCodeWrapper, conn: &DbConn) -> ApiResult { - let code = sso::encode_code_claims(wrapper); +async fn oidcsignin_redirect( + base64_state: String, + wrapper: impl FnOnce(String) -> sso::OIDCCodeWrapper, + conn: &DbConn, +) -> ApiResult { + let state = match data_encoding::BASE64.decode(base64_state.as_bytes()) { + Ok(vec) => match String::from_utf8(vec) { + Ok(valid) => valid, + Err(_) => err!(format!("Invalid utf8 chars in {base64_state} after base64 decoding")), + }, + Err(_) => err!(format!("Failed to decode {base64_state} using base64")), + }; + let code = sso::encode_code_claims(wrapper(state.clone())); let nonce = match SsoNonce::find(&state, conn).await { Some(n) => n, diff --git a/src/sso.rs b/src/sso.rs index ad57fbbce9..0df5700c43 100644 --- a/src/sso.rs +++ b/src/sso.rs @@ -81,8 +81,8 @@ pub fn encode_ssotoken_claims() -> String { #[derive(Debug, Serialize, Deserialize)] pub enum OIDCCodeWrapper { Ok { - code: String, state: String, + code: String, }, Error { state: String, @@ -209,9 +209,11 @@ impl CoreClientExt for CoreClient { } // The `nonce` allow to protect against replay attacks +// The `state` is encoded using base64 to ensure no issue with providers (It contains the Organization identifier). // redirect_uri from: https://github.com/bitwarden/server/blob/main/src/Identity/IdentityServer/ApiClient.cs pub async fn authorize_url(state: String, client_id: &str, raw_redirect_uri: &str, mut conn: DbConn) -> ApiResult { let scopes = CONFIG.sso_scopes_vec().into_iter().map(Scope::new); + let base64_state = data_encoding::BASE64.encode(state.as_bytes()); let redirect_uri = match client_id { "web" | "browser" => format!("{}/sso-connector.html", CONFIG.domain()), @@ -230,7 +232,7 @@ pub async fn authorize_url(state: String, client_id: &str, raw_redirect_uri: &st let mut auth_req = client .authorize_url( AuthenticationFlow::::AuthorizationCode, - || CsrfToken::new(state), + || CsrfToken::new(base64_state), Nonce::new_random, ) .add_scopes(scopes) @@ -244,9 +246,9 @@ pub async fn authorize_url(state: String, client_id: &str, raw_redirect_uri: &st None }; - let (auth_url, csrf_state, nonce) = auth_req.url(); + let (auth_url, _, nonce) = auth_req.url(); - let sso_nonce = SsoNonce::new(csrf_state.secret().to_string(), nonce.secret().to_string(), verifier, redirect_uri); + let sso_nonce = SsoNonce::new(state, nonce.secret().to_string(), verifier, redirect_uri); sso_nonce.save(&mut conn).await?; Ok(auth_url) @@ -276,8 +278,8 @@ async fn decode_code_claims(code: &str, conn: &mut DbConn) -> ApiResult<(String, match auth::decode_jwt::(code, SSO_JWT_ISSUER.to_string()) { Ok(code_claims) => match code_claims.code { OIDCCodeWrapper::Ok { - code, state, + code, } => Ok((code, state)), OIDCCodeWrapper::Error { state, From 968865e2f83145591b062a131f226924b9eeec26 Mon Sep 17 00:00:00 2001 From: Timshel Date: Thu, 28 Nov 2024 16:44:37 +0100 Subject: [PATCH 10/10] Prevent disabled User from SSO login --- src/api/identity.rs | 49 +++++++++++++++++++++++++++++++++++++++------ src/error.rs | 6 ++++++ 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/src/api/identity.rs b/src/api/identity.rs index 78c814f54b..c5ebcf3807 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -152,7 +152,12 @@ async fn _sso_login(data: ConnectData, user_uuid: &mut Option, conn: &mu crate::ratelimit::check_limit_login(&ip.ip)?; let code = match data.code.as_ref() { - None => err!("Got no code in OIDC data"), + None => err!( + "Got no code in OIDC data", + ErrorEvent { + event: EventType::UserFailedLogIn + } + ), Some(code) => code, }; @@ -166,14 +171,33 @@ async fn _sso_login(data: ConnectData, user_uuid: &mut Option, conn: &mu "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") + err_silent!( + "Existing non SSO user with same email", + ErrorEvent { + event: EventType::UserFailedLogIn + } + ) } Some((user, Some(sso_user))) if sso_user.identifier != user_infos.identifier => { error!( "Login failure ({}), existing SSO user ({}) with same email ({})", user_infos.identifier, user.uuid, user.email ); - err_silent!("Existing SSO user with same email") + err_silent!( + "Existing SSO user with same email", + ErrorEvent { + event: EventType::UserFailedLogIn + } + ) + } + Some((user, _)) if !user.enabled => { + err!( + "This user has been disabled", + format!("IP: {}. Username: {}.", ip.ip, user.name), + ErrorEvent { + event: EventType::UserFailedLogIn + } + ) } Some((user, sso_user)) => { let (mut device, new_device) = get_device(&data, conn, &user).await?; @@ -190,15 +214,28 @@ async fn _sso_login(data: ConnectData, user_uuid: &mut Option, conn: &mu let (user, mut device, new_device, twofactor_token, sso_user) = match user_data { None => { if !CONFIG.is_email_domain_allowed(&user_infos.email) { - err!("Email domain not allowed"); + 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." + 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 + } ), - Some(false) => err!("You need to verify your email with your provider before you can log in"), _ => (), } diff --git a/src/error.rs b/src/error.rs index bf9f2cf405..d0048b0c78 100644 --- a/src/error.rs +++ b/src/error.rs @@ -255,9 +255,15 @@ macro_rules! err_silent { ($msg:expr) => {{ return Err($crate::error::Error::new($msg, $msg)); }}; + ($msg:expr, ErrorEvent $err_event:tt) => {{ + return Err($crate::error::Error::new($msg, $msg).with_event($crate::error::ErrorEvent $err_event)); + }}; ($usr_msg:expr, $log_value:expr) => {{ return Err($crate::error::Error::new($usr_msg, $log_value)); }}; + ($usr_msg:expr, $log_value:expr, ErrorEvent $err_event:tt) => {{ + return Err($crate::error::Error::new($usr_msg, $log_value).with_event($crate::error::ErrorEvent $err_event)); + }}; } #[macro_export]