diff --git a/Cargo.lock b/Cargo.lock index b6ea2ad0..a75cecd7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2675,6 +2675,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c3f9a28b618c3a6b9251b6908e9c99e04b9e5c02e6581ccbb67d59c34ef7f9b" dependencies = [ "itoa", + "js-sys", "libc", "num_threads", "time-macros", diff --git a/Cargo.toml b/Cargo.toml index 583fe710..c5e4c26b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -138,6 +138,10 @@ pico-args = "0.5.0" paste = "1.0.9" governor = "0.5.0" +# OIDC SSo +openidconnect = "2.3.2" + + # Capture CTRL+C ctrlc = { version = "3.2.3", features = ["termination"] } diff --git a/migrations/mysql/2021-09-16-133000_add_sso/down.sql b/migrations/mysql/2021-09-16-133000_add_sso/down.sql new file mode 100644 index 00000000..ade3aeed --- /dev/null +++ b/migrations/mysql/2021-09-16-133000_add_sso/down.sql @@ -0,0 +1,2 @@ +DROP TABLE sso_nonce; +DROP TABLE sso_config; diff --git a/migrations/mysql/2021-09-16-133000_add_sso/up.sql b/migrations/mysql/2021-09-16-133000_add_sso/up.sql new file mode 100644 index 00000000..e4210214 --- /dev/null +++ b/migrations/mysql/2021-09-16-133000_add_sso/up.sql @@ -0,0 +1,18 @@ +ALTER TABLE organizations ADD COLUMN identifier TEXT; + +CREATE TABLE sso_nonce ( + uuid CHAR(36) NOT NULL PRIMARY KEY, + org_uuid CHAR(36) NOT NULL REFERENCES organizations (uuid), + nonce CHAR(36) NOT NULL +); + +CREATE TABLE sso_config ( + uuid CHAR(36) NOT NULL PRIMARY KEY, + org_uuid CHAR(36) NOT NULL REFERENCES organizations(uuid), + use_sso BOOLEAN NOT NULL, + callback_path TEXT NOT NULL, + signed_out_callback_path TEXT NOT NULL, + authority TEXT, + client_id TEXT, + client_secret TEXT +); diff --git a/migrations/postgresql/2019-09-12-100000_create_tables/up.sql b/migrations/postgresql/2019-09-12-100000_create_tables/up.sql index c747e9aa..d66435b2 100644 --- a/migrations/postgresql/2019-09-12-100000_create_tables/up.sql +++ b/migrations/postgresql/2019-09-12-100000_create_tables/up.sql @@ -118,4 +118,4 @@ CREATE TABLE twofactor ( CREATE TABLE invitations ( email VARCHAR(255) NOT NULL PRIMARY KEY -); \ No newline at end of file +); diff --git a/migrations/postgresql/2021-09-16-133000_add_sso/down.sql b/migrations/postgresql/2021-09-16-133000_add_sso/down.sql new file mode 100644 index 00000000..ade3aeed --- /dev/null +++ b/migrations/postgresql/2021-09-16-133000_add_sso/down.sql @@ -0,0 +1,2 @@ +DROP TABLE sso_nonce; +DROP TABLE sso_config; diff --git a/migrations/postgresql/2021-09-16-133000_add_sso/up.sql b/migrations/postgresql/2021-09-16-133000_add_sso/up.sql new file mode 100644 index 00000000..e4210214 --- /dev/null +++ b/migrations/postgresql/2021-09-16-133000_add_sso/up.sql @@ -0,0 +1,18 @@ +ALTER TABLE organizations ADD COLUMN identifier TEXT; + +CREATE TABLE sso_nonce ( + uuid CHAR(36) NOT NULL PRIMARY KEY, + org_uuid CHAR(36) NOT NULL REFERENCES organizations (uuid), + nonce CHAR(36) NOT NULL +); + +CREATE TABLE sso_config ( + uuid CHAR(36) NOT NULL PRIMARY KEY, + org_uuid CHAR(36) NOT NULL REFERENCES organizations(uuid), + use_sso BOOLEAN NOT NULL, + callback_path TEXT NOT NULL, + signed_out_callback_path TEXT NOT NULL, + authority TEXT, + client_id TEXT, + client_secret TEXT +); diff --git a/migrations/sqlite/2021-09-16-133000_add_sso/down.sql b/migrations/sqlite/2021-09-16-133000_add_sso/down.sql new file mode 100644 index 00000000..ade3aeed --- /dev/null +++ b/migrations/sqlite/2021-09-16-133000_add_sso/down.sql @@ -0,0 +1,2 @@ +DROP TABLE sso_nonce; +DROP TABLE sso_config; diff --git a/migrations/sqlite/2021-09-16-133000_add_sso/up.sql b/migrations/sqlite/2021-09-16-133000_add_sso/up.sql new file mode 100644 index 00000000..e4210214 --- /dev/null +++ b/migrations/sqlite/2021-09-16-133000_add_sso/up.sql @@ -0,0 +1,18 @@ +ALTER TABLE organizations ADD COLUMN identifier TEXT; + +CREATE TABLE sso_nonce ( + uuid CHAR(36) NOT NULL PRIMARY KEY, + org_uuid CHAR(36) NOT NULL REFERENCES organizations (uuid), + nonce CHAR(36) NOT NULL +); + +CREATE TABLE sso_config ( + uuid CHAR(36) NOT NULL PRIMARY KEY, + org_uuid CHAR(36) NOT NULL REFERENCES organizations(uuid), + use_sso BOOLEAN NOT NULL, + callback_path TEXT NOT NULL, + signed_out_callback_path TEXT NOT NULL, + authority TEXT, + client_id TEXT, + client_secret TEXT +); diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 3934de88..0fd1e398 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -31,6 +31,8 @@ pub fn routes() -> Vec { put_collection_users, put_organization, post_organization, + get_organization_sso, + put_organization_sso, post_organization_collections, delete_organization_collection_user, post_organization_collection_delete_user, @@ -92,6 +94,14 @@ struct OrgData { struct OrganizationUpdateData { BillingEmail: String, Name: String, + Identifier: Option, +} + +#[derive(Deserialize, Debug)] +#[allow(non_snake_case)] +struct OrganizationSsoUpdateData { + Enabled: Option, + Data: Option, } #[derive(Deserialize, Debug)] @@ -100,6 +110,89 @@ struct NewCollectionData { Name: String, } +/* + From Bitwarden Entreprise + + + + +{ + "enabled": false, + "data": { + "acrValues": "requested authentication context class", + "additionalEmailClaimTypes": "additinaional email", + "additionalNameClaimTypes": "additioonal name claim tyeps", + "additionalScopes": "additonal scopes", + "additionalUserIdClaimTypes": "additoal userid", + "authority": "authority", + "clientId": "clientid", + "clientSecret": "clientsecrte", + "configType": 1, + "expectedReturnAcrValue": "expectde acr", + "getClaimsFromUserInfoEndpoint": true, + "idpAllowUnsolicitedAuthnResponse": false, + "idpArtifactResolutionServiceUrl": null, + "idpBindingType": 1, + "idpDisableOutboundLogoutRequests": false, + "idpEntityId": null, + "idpOutboundSigningAlgorithm": "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", + "idpSingleLogoutServiceUrl": null, + "idpSingleSignOnServiceUrl": null, + "idpWantAuthnRequestsSigned": false + "idpX509PublicCert": null, + "keyConnectorEnabled": false, + "keyConnectorUrl": null, + "metadataAddress": "metadata adress", + "redirectBehavior": 1, + "spMinIncomingSigningAlgorithm": "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", + "spNameIdFormat": 7, + "spOutboundSigningAlgorithm": "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", + "spSigningBehavior": 0, + "spValidateCertificates": false, + "spWantAssertionsSigned": false, + } +} +*/ + +#[derive(Deserialize, Debug)] +#[allow(non_snake_case)] +struct SsoOrganizationData { + // authority: Option, + // clientId: Option, + // clientSecret: Option, + AcrValues: Option, + AdditionalEmailClaimTypes: Option, + AdditionalNameClaimTypes: Option, + AdditionalScopes: Option, + AdditionalUserIdClaimTypes: Option, + Authority: Option, + ClientId: Option, + ClientSecret: Option, + ConfigType: Option, + ExpectedReturnAcrValue: Option, + GetClaimsFromUserInfoEndpoint: Option, + IdpAllowUnsolicitedAuthnResponse: Option, + IdpArtifactResolutionServiceUrl: Option, + IdpBindingType: Option, + IdpDisableOutboundLogoutRequests: Option, + IdpEntityId: Option, + IdpOutboundSigningAlgorithm: Option, + IdpSingleLogoutServiceUrl: Option, + IdpSingleSignOnServiceUrl: Option, + IdpWantAuthnRequestsSigned: Option, + IdpX509PublicCert: Option, + KeyConnectorUrlY: Option, + KeyConnectorEnabled: Option, + MetadataAddress: Option, + RedirectBehavior: Option, + SpMinIncomingSigningAlgorithm: Option, + SpNameIdFormat: Option, + SpOutboundSigningAlgorithm: Option, + SpSigningBehavior: Option, + SpValidateCertificates: Option, + SpWantAssertionsSigned: Option, +} + #[derive(Deserialize)] #[allow(non_snake_case)] struct OrgKeyData { @@ -134,6 +227,7 @@ async fn create_organization(headers: Headers, data: JsonUpcase, conn: let org = Organization::new(data.Name, data.BillingEmail, private_key, public_key); let mut user_org = UserOrganization::new(headers.user.uuid, org.uuid.clone()); + let sso_config = SsoConfig::new(org.uuid.clone()); let collection = Collection::new(org.uuid.clone(), data.CollectionName); user_org.akey = data.Key; @@ -143,6 +237,7 @@ async fn create_organization(headers: Headers, data: JsonUpcase, conn: org.save(&conn).await?; user_org.save(&conn).await?; + sso_config.save(&conn).await?; collection.save(&conn).await?; Ok(Json(org.to_json())) @@ -228,11 +323,123 @@ async fn post_organization( org.name = data.Name; org.billing_email = data.BillingEmail; + org.identifier = data.Identifier; org.save(&conn).await?; Ok(Json(org.to_json())) } +#[get("/organizations//sso")] +async fn get_organization_sso(org_id: String, _headers: OwnerHeaders, conn: DbConn) -> JsonResult { + match SsoConfig::find_by_org(&org_id, &conn).await { + Some(sso_config) => { + let config_json = Json(sso_config.to_json()); + Ok(config_json) + } + None => err!("Can't find organization sso config"), + } +} + +#[post("/organizations//sso", data = "")] +async fn put_organization_sso( + org_id: String, + _headers: OwnerHeaders, + data: JsonUpcase, + conn: DbConn, +) -> JsonResult { + let p: OrganizationSsoUpdateData = data.into_inner().data; + let d: SsoOrganizationData = p.Data.unwrap(); + + // TODO remove after debugging + println!( + " + p.Enabled: {:?}, + d.AcrValues: {:?}, + d.AdditionalEmailClaimTypes: {:?}, + d.AdditionalNameClaimTypes: {:?}, + d.AdditionalScopes: {:?}, + d.AdditionalUserIdClaimTypes: {:?}, + d.Authority: {:?}, + d.ClientId: {:?}, + d.ClientSecret: {:?}, + d.ConfigType: {:?}, + d.ExpectedReturnAcrValue: {:?}, + d.GetClaimsFromUserInfoEndpoint: {:?}, + d.IdpAllowUnsolicitedAuthnResponse: {:?}, + d.IdpArtifactResolutionServiceUrl: {:?}, + d.IdpBindingType: {:?}, + d.IdpDisableOutboundLogoutRequests: {:?}, + d.IdpEntityId: {:?}, + d.IdpOutboundSigningAlgorithm: {:?}, + d.IdpSingleLogoutServiceUrl: {:?}, + d.IdpSingleSignOnServiceUrl: {:?}, + d.IdpWantAuthnRequestsSigned: {:?}, + d.IdpX509PublicCert: {:?}, + d.KeyConnectorUrlY: {:?}, + d.KeyConnectorEnabled: {:?}, + d.MetadataAddress: {:?}, + d.RedirectBehavior: {:?}, + d.SpMinIncomingSigningAlgorithm: {:?}, + d.SpNameIdFormat: {:?}, + d.SpOutboundSigningAlgorithm: {:?}, + d.SpSigningBehavior: {:?}, + d.SpValidateCertificates: {:?}, + d.SpWantAssertionsSigned: {:?}", + p.Enabled.unwrap_or_default(), + d.AcrValues, + d.AdditionalEmailClaimTypes, + d.AdditionalNameClaimTypes, + d.AdditionalScopes, + d.AdditionalUserIdClaimTypes, + d.Authority, + d.ClientId, + d.ClientSecret, + d.ConfigType, + d.ExpectedReturnAcrValue, + d.GetClaimsFromUserInfoEndpoint, + d.IdpAllowUnsolicitedAuthnResponse, + d.IdpArtifactResolutionServiceUrl, + d.IdpBindingType, + d.IdpDisableOutboundLogoutRequests, + d.IdpEntityId, + d.IdpOutboundSigningAlgorithm, + d.IdpSingleLogoutServiceUrl, + d.IdpSingleSignOnServiceUrl, + d.IdpWantAuthnRequestsSigned, + d.IdpX509PublicCert, + d.KeyConnectorUrlY, + d.KeyConnectorEnabled, + d.MetadataAddress, + d.RedirectBehavior, + d.SpMinIncomingSigningAlgorithm, + d.SpNameIdFormat, + d.SpOutboundSigningAlgorithm, + d.SpSigningBehavior, + d.SpValidateCertificates, + d.SpWantAssertionsSigned + ); + + let mut sso_config = match SsoConfig::find_by_org(&org_id, &conn).await { + Some(sso_config) => sso_config, + None => SsoConfig::new(org_id), + }; + + sso_config.use_sso = p.Enabled.unwrap_or_default(); + + // let sso_config_data = data.Data.unwrap(); + + // TODO use real values + sso_config.callback_path = "http://localhost:8000/#/sso".to_string(); //data.CallbackPath; + sso_config.signed_out_callback_path = "http://localhost:8000/#/sso".to_string(); //data2.Data.unwrap().call + + sso_config.authority = d.Authority; + sso_config.client_id = d.ClientId; + sso_config.client_secret = d.ClientSecret; + + sso_config.save(&conn).await?; + Ok(Json(sso_config.to_json())) +} + // GET /api/collections?writeOnly=false #[get("/collections")] async fn get_user_collections(headers: Headers, conn: DbConn) -> Json { diff --git a/src/api/identity.rs b/src/api/identity.rs index d0a3bcce..70aa3020 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -1,11 +1,15 @@ use chrono::Utc; +use jsonwebtoken::DecodingKey; use num_traits::FromPrimitive; use rocket::serde::json::Json; use rocket::{ form::{Form, FromForm}, + http::Status, + response::Redirect, Route, }; use serde_json::Value; +use std::iter::FromIterator; use crate::{ api::{ @@ -20,7 +24,7 @@ use crate::{ }; pub fn routes() -> Vec { - routes![login, prelogin] + routes![login, prelogin, prevalidate, authorize] } #[post("/connect/token", data = "")] @@ -51,6 +55,13 @@ async fn login(data: Form, conn: DbConn, ip: ClientIp) -> JsonResul _api_key_login(data, conn, &ip).await } + "authorization_code" => { + _check_is_some(&data.code, "code cannot be blank")?; + _check_is_some(&data.org_identifier, "org_identifier cannot be blank")?; + _check_is_some(&data.device_identifier, "device identifier cannot be blank")?; + + _authorization_login(data, conn, &ip).await + } t => err!("Invalid type", t), } } @@ -87,6 +98,104 @@ async fn _refresh_login(data: ConnectData, conn: DbConn) -> JsonResult { }))) } +#[derive(Debug, Serialize, Deserialize)] +struct TokenPayload { + exp: i64, + email: String, + nonce: String, +} + +async fn _authorization_login(data: ConnectData, conn: DbConn, ip: &ClientIp) -> JsonResult { + let org_identifier = data.org_identifier.as_ref().unwrap(); + let code = data.code.as_ref().unwrap(); + + let organization = Organization::find_by_identifier(org_identifier, &conn).await.unwrap(); + let sso_config = SsoConfig::find_by_org(&organization.uuid, &conn).await.unwrap(); + + let (_access_token, refresh_token, id_token) = match get_auth_code_access_token(code, &sso_config).await { + Ok((_access_token, refresh_token, id_token)) => (_access_token, refresh_token, id_token), + Err(err) => err!(err), + }; + + // https://github.com/Keats/jsonwebtoken/issues/236#issuecomment-1093039195 + // let token = jsonwebtoken::decode::(access_token.as_str()).unwrap().claims; + let mut validation = jsonwebtoken::Validation::default(); + validation.insecure_disable_signature_validation(); + + let token = jsonwebtoken::decode::(id_token.as_str(), &DecodingKey::from_secret(&[]), &validation) + .unwrap() + .claims; + + // let expiry = token.exp; + let nonce = token.nonce; + + match SsoNonce::find_by_org_and_nonce(&organization.uuid, &nonce, &conn).await { + Some(sso_nonce) => { + match sso_nonce.delete(&conn).await { + Ok(_) => { + // let expiry = token.exp; + let user_email = token.email; + let now = Utc::now().naive_utc(); + + // COMMON + // TODO handle missing users, currently this will panic if the user does not exist! + let user = User::find_by_mail(&user_email, &conn).await.unwrap(); + + let (mut device, new_device) = get_device(&data, &conn, &user).await; + + let twofactor_token = twofactor_auth(&user.uuid, &data, &mut device, ip, &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.") + } + } + } + + device.refresh_token = refresh_token.clone(); + device.save(&conn).await?; + + let scope_vec = vec!["api".into(), "offline_access".into()]; + let orgs = UserOrganization::find_confirmed_by_user(&user.uuid, &conn).await; + let (access_token_new, expires_in) = device.refresh_tokens(&user, orgs, scope_vec); + device.save(&conn).await?; + + let mut result = json!({ + "access_token": access_token_new, + "expires_in": expires_in, + "token_type": "Bearer", + "refresh_token": refresh_token, + "Key": user.akey, + "PrivateKey": user.private_key, + + "Kdf": user.client_kdf_type, + "KdfIterations": user.client_kdf_iter, + "ResetMasterPassword": false, // TODO: according to official server seems something like: user.password_hash.is_empty(), but would need testing + "scope": "api offline_access", + "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") + } + } +} + async fn _password_login(data: ConnectData, conn: DbConn, ip: &ClientIp) -> JsonResult { // Validate scope let scope = data.scope.as_ref().unwrap(); @@ -116,6 +225,15 @@ async fn _password_login(data: ConnectData, conn: DbConn, ip: &ClientIp) -> Json err!("This user has been disabled", format!("IP: {}. Username: {}.", ip.ip, username)) } + // Check if org policy prevents password login + let user_orgs = UserOrganization::find_by_user_and_policy(&user.uuid, OrgPolicyType::RequireSso, &conn).await; + if !user_orgs.is_empty() && user_orgs[0].atype != UserOrgType::Owner && user_orgs[0].atype != UserOrgType::Admin { + // if requires SSO is active, user is in exactly one org by policy rules + // policy only applies to "non-owner/non-admin" members + + err!("Organization policy requires SSO sign in"); + } + let now = Utc::now().naive_utc(); if user.verified_at.is_none() && CONFIG.mail_enabled() && CONFIG.signups_verify() { @@ -486,11 +604,137 @@ struct ConnectData { #[field(name = uncased("two_factor_remember"))] #[field(name = uncased("twofactorremember"))] two_factor_remember: Option, + + // Needed for authorization code + code: Option, + org_identifier: Option, } +// TODO Might need to migrate this: https://github.com/SergioBenitez/Rocket/pull/1489#issuecomment-1114750006 + fn _check_is_some(value: &Option, msg: &str) -> EmptyResult { if value.is_none() { err!(msg) } Ok(()) } + +#[get("/account/prevalidate?")] +#[allow(non_snake_case)] +// The compiler warns about unreachable code here. But I've tested it, and it seems to work +// as expected. All errors appear to be reachable, as is the Ok response. +#[allow(unreachable_code)] +async fn prevalidate(domainHint: String, conn: DbConn) -> JsonResult { + let empty_result = json!({}); + + // TODO: fix panic on failig to retrive (no unwrap on null) + let organization = Organization::find_by_identifier(&domainHint, &conn).await.unwrap(); + + let sso_config = SsoConfig::find_by_org(&organization.uuid, &conn); + match sso_config.await { + Some(sso_config) => { + if !sso_config.use_sso { + return err_code!("SSO Not allowed for organization", Status::BadRequest.code); + } + if sso_config.authority.is_none() || sso_config.client_id.is_none() || sso_config.client_secret.is_none() { + return err_code!("Organization is incorrectly configured for SSO", Status::BadRequest.code); + } + } + None => { + return err_code!("Unable to find sso config", Status::BadRequest.code); + } + } + + if domainHint.is_empty() { + return err_code!("No Organization Identifier Provided", Status::BadRequest.code); + } + + Ok(Json(empty_result)) +} + +use openidconnect::core::{CoreClient, CoreProviderMetadata, CoreResponseType}; +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(sso_config: &SsoConfig) -> Result { + let redirect = sso_config.callback_path.to_string(); + let client_id = ClientId::new(sso_config.client_id.as_ref().unwrap().to_string()); + let client_secret = ClientSecret::new(sso_config.client_secret.as_ref().unwrap().to_string()); + let issuer_url = + IssuerUrl::new(sso_config.authority.as_ref().unwrap().to_string()).or(Err("invalid issuer URL"))?; + + // TODO: This comparison will fail if one URI has a trailing slash and the other one does not. + // Should we remove trailing slashes when saving? Or when checking? + let provider_metadata = match CoreProviderMetadata::discover_async(issuer_url, async_http_client).await { + Ok(metadata) => metadata, + Err(_err) => { + return Err("Failed to discover OpenID provider"); + } + }; + + let client = CoreClient::from_provider_metadata(provider_metadata, client_id, Some(client_secret)) + .set_redirect_uri(RedirectUrl::new(redirect).or(Err("Invalid redirect URL"))?); + + Ok(client) +} + +#[get("/connect/authorize?&")] +async fn authorize(domain_hint: String, state: String, conn: DbConn) -> ApiResult { + let organization = Organization::find_by_identifier(&domain_hint, &conn).await.unwrap(); + let sso_config = SsoConfig::find_by_org(&organization.uuid, &conn).await.unwrap(); + + match get_client_from_sso_config(&sso_config).await { + Ok(client) => { + let (mut authorize_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(organization.uuid, nonce.secret().to_string()); + sso_nonce.save(&conn).await?; + + // it seems impossible to set the state going in dynamically (requires static lifetime string) + // so I change it after the fact + let old_pairs = authorize_url.query_pairs(); + let new_pairs = old_pairs.map(|pair| { + let (key, value) = pair; + if key == "state" { + return format!("{}={}", key, state); + } + return format!("{}={}", key, value); + }); + let full_query = Vec::from_iter(new_pairs).join("&"); + authorize_url.set_query(Some(full_query.as_str())); + + Ok(Redirect::to(authorize_url.to_string())) + } + Err(err) => err!("Unable to find client from identifier {}", err), + } +} + +async fn get_auth_code_access_token( + code: &str, + sso_config: &SsoConfig, +) -> Result<(String, String, String), &'static str> { + let oidc_code = AuthorizationCode::new(String::from(code)); + match get_client_from_sso_config(sso_config).await { + Ok(client) => match client.exchange_code(oidc_code).request_async(async_http_client).await { + Ok(token_response) => { + let access_token = token_response.access_token().secret().to_string(); + let refresh_token = token_response.refresh_token().unwrap().secret().to_string(); + let id_token = token_response.extra_fields().id_token().unwrap().to_string(); + Ok((access_token, refresh_token, id_token)) + } + Err(_err) => Err("Failed to contact token endpoint"), + }, + Err(_err) => Err("unable to find client"), + } +} diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index eb425d1a..84635a97 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -8,6 +8,8 @@ mod folder; mod org_policy; mod organization; mod send; +mod sso_config; +mod sso_nonce; mod two_factor; mod two_factor_incomplete; mod user; @@ -22,6 +24,8 @@ pub use self::folder::{Folder, FolderCipher}; pub use self::org_policy::{OrgPolicy, OrgPolicyErr, OrgPolicyType}; pub use self::organization::{Organization, UserOrgStatus, UserOrgType, UserOrganization}; pub use self::send::{Send, SendType}; +pub use self::sso_config::SsoConfig; +pub use self::sso_nonce::SsoNonce; pub use self::two_factor::{TwoFactor, TwoFactorType}; pub use self::two_factor_incomplete::TwoFactorIncomplete; pub use self::user::{Invitation, User, UserStampException}; diff --git a/src/db/models/org_policy.rs b/src/db/models/org_policy.rs index 02ca8408..65cbf5e1 100644 --- a/src/db/models/org_policy.rs +++ b/src/db/models/org_policy.rs @@ -28,7 +28,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 99787eb8..c9edab96 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -12,6 +12,7 @@ db_object! { pub uuid: String, pub name: String, pub billing_email: String, + pub identifier: Option, pub private_key: Option, pub public_key: Option, } @@ -133,13 +134,14 @@ impl Organization { billing_email, private_key, public_key, + identifier: None, } } // https://github.com/bitwarden/server/blob/13d1e74d6960cf0d042620b72d85bf583a4236f7/src/Api/Models/Response/Organizations/OrganizationResponseModel.cs pub fn to_json(&self) -> Value { json!({ "Id": self.uuid, - "Identifier": null, // not supported by us + "Identifier": self.identifier, "Name": self.name, "Seats": 10, // The value doesn't matter, we don't check server-side // "MaxAutoscaleSeats": null, // The value doesn't matter, we don't check server-side @@ -151,9 +153,6 @@ impl Organization { "UseGroups": false, // Not supported "UseTotp": true, "UsePolicies": true, - // "UseScim": false, // Not supported (Not AGPLv3 Licensed) - "UseSso": false, // Not supported - // "UseKeyConnector": false, // Not supported "SelfHost": true, "UseApi": false, // Not supported "HasPublicAndPrivateKeys": self.private_key.is_some() && self.public_key.is_some(), @@ -277,6 +276,15 @@ impl Organization { }} } + pub async fn find_by_identifier(identifier: &str, conn: &DbConn) -> Option { + db_run! { conn: { + organizations::table + .filter(organizations::identifier.eq(identifier)) + .first::(conn) + .ok().from_db() + }} + } + pub async fn get_all(conn: &DbConn) -> Vec { db_run! { conn: { organizations::table.load::(conn).expect("Error loading organizations").from_db() @@ -307,9 +315,14 @@ impl UserOrganization { "UseApi": false, // Not supported "SelfHost": true, "HasPublicAndPrivateKeys": org.private_key.is_some() && org.public_key.is_some(), - "ResetPasswordEnrolled": false, // Not supported - "SsoBound": false, // Not supported - "UseSso": false, // Not supported + "ResetPasswordEnrolled": false, // not supported by us + "SsoBound": true, + "UseSso": true, + // TODO: Add support for Business Portal + // Upstream is moving Policies and SSO management outside of the web-vault to /portal + // For now they still have that code also in the web-vault, but they will remove it at some point. + // https://github.com/bitwarden/server/tree/master/bitwarden_license/src/ + "UseBusinessPortal": false, // Disable BusinessPortal Button "ProviderId": null, "ProviderName": null, // "KeyConnectorEnabled": false, diff --git a/src/db/models/sso_config.rs b/src/db/models/sso_config.rs new file mode 100644 index 00000000..e8073535 --- /dev/null +++ b/src/db/models/sso_config.rs @@ -0,0 +1,104 @@ +use crate::api::EmptyResult; +use crate::db::DbConn; +use crate::error::MapResult; +use serde_json::Value; + +use super::Organization; + +db_object! { + #[derive(Identifiable, Queryable, Insertable, Associations, AsChangeset)] + #[table_name = "sso_config"] + #[belongs_to(Organization, foreign_key = "org_uuid")] + #[primary_key(uuid)] + pub struct SsoConfig { + pub uuid: String, + pub org_uuid: String, + pub use_sso: bool, + pub callback_path: String, + pub signed_out_callback_path: String, + pub authority: Option, + pub client_id: Option, + pub client_secret: Option, + } +} + +/// Local methods +impl SsoConfig { + pub fn new(org_uuid: String) -> Self { + Self { + uuid: crate::util::get_uuid(), + org_uuid, + use_sso: false, + callback_path: String::from("http://localhost/#/sso/"), + signed_out_callback_path: String::from("http://localhost/#/sso/"), + authority: None, + client_id: None, + client_secret: None, + } + } + + pub fn to_json(&self) -> Value { + json!({ + "Id": self.uuid, + "UseSso": self.use_sso, + "CallbackPath": self.callback_path, + "SignedOutCallbackPath": self.signed_out_callback_path, + "Authority": self.authority, + "ClientId": self.client_id, + "ClientSecret": self.client_secret, + }) + } +} + +/// Database methods +impl SsoConfig { + pub async fn save(&self, conn: &DbConn) -> EmptyResult { + db_run! { conn: + sqlite, mysql { + match diesel::replace_into(sso_config::table) + .values(SsoConfigDb::to_db(self)) + .execute(conn) + { + Ok(_) => Ok(()), + // Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first. + Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { + diesel::update(sso_config::table) + .filter(sso_config::uuid.eq(&self.uuid)) + .set(SsoConfigDb::to_db(self)) + .execute(conn) + .map_res("Error adding sso config to organization") + } + Err(e) => Err(e.into()), + }.map_res("Error adding sso config to organization") + } + postgresql { + let value = SsoConfigDb::to_db(self); + diesel::insert_into(sso_config::table) + .values(&value) + .on_conflict(sso_config::uuid) + .do_update() + .set(&value) + .execute(conn) + .map_res("Error adding sso config to organization") + } + } + } + + pub async fn delete(self, conn: &DbConn) -> EmptyResult { + db_run! { conn: { + diesel::delete(sso_config::table.filter(sso_config::uuid.eq(self.uuid))) + .execute(conn) + .map_res("Error deleting SSO Config") + }} + } + + pub async fn find_by_org(org_uuid: &str, conn: &DbConn) -> Option { + db_run! { conn: { + sso_config::table + .filter(sso_config::org_uuid.eq(org_uuid)) + .first::(conn) + .ok() + .from_db() + }} + } +} diff --git a/src/db/models/sso_nonce.rs b/src/db/models/sso_nonce.rs new file mode 100644 index 00000000..0897a186 --- /dev/null +++ b/src/db/models/sso_nonce.rs @@ -0,0 +1,71 @@ +use crate::api::EmptyResult; +use crate::db::DbConn; +use crate::error::MapResult; + +use super::Organization; + +db_object! { + #[derive(Identifiable, Queryable, Insertable, Associations, AsChangeset)] + #[table_name = "sso_nonce"] + #[belongs_to(Organization, foreign_key = "org_uuid")] + #[primary_key(uuid)] + pub struct SsoNonce { + pub uuid: String, + pub org_uuid: String, + pub nonce: String, + } +} + +/// Local methods +impl SsoNonce { + pub fn new(org_uuid: String, nonce: String) -> Self { + Self { + uuid: crate::util::get_uuid(), + org_uuid, + nonce, + } + } +} + +/// Database methods +impl SsoNonce { + pub async fn save(&self, conn: &DbConn) -> EmptyResult { + db_run! { conn: + sqlite, mysql { + diesel::replace_into(sso_nonce::table) + .values(SsoNonceDb::to_db(self)) + .execute(conn) + .map_res("Error saving device") + } + postgresql { + let value = SsoNonceDb::to_db(self); + diesel::insert_into(sso_nonce::table) + .values(&value) + .on_conflict(sso_nonce::uuid) + .do_update() + .set(&value) + .execute(conn) + .map_res("Error saving SSO nonce") + } + } + } + + pub async fn delete(self, conn: &DbConn) -> EmptyResult { + db_run! { conn: { + diesel::delete(sso_nonce::table.filter(sso_nonce::uuid.eq(self.uuid))) + .execute(conn) + .map_res("Error deleting SSO nonce") + }} + } + + pub async fn find_by_org_and_nonce(org_uuid: &str, nonce: &str, conn: &DbConn) -> Option { + db_run! { conn: { + sso_nonce::table + .filter(sso_nonce::org_uuid.eq(org_uuid)) + .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 a49159f2..1b08ad15 100644 --- a/src/db/schemas/mysql/schema.rs +++ b/src/db/schemas/mysql/schema.rs @@ -100,11 +100,25 @@ table! { uuid -> Text, name -> Text, billing_email -> Text, + identifier -> Nullable, private_key -> Nullable, public_key -> Nullable, } } +table! { + sso_config (uuid) { + uuid -> Text, + org_uuid -> Text, + use_sso -> Bool, + callback_path -> Text, + signed_out_callback_path -> Text, + authority -> Nullable, + client_id -> Nullable, + client_secret -> Nullable, + } +} + table! { sends (uuid) { uuid -> Text, @@ -203,6 +217,14 @@ table! { } } +table! { + sso_nonce (uuid) { + uuid -> Text, + org_uuid -> Text, + nonce -> Text, + } +} + table! { emergency_access (uuid) { uuid -> Text, @@ -239,6 +261,7 @@ joinable!(users_collections -> users (user_uuid)); joinable!(users_organizations -> organizations (org_uuid)); joinable!(users_organizations -> users (user_uuid)); joinable!(emergency_access -> users (grantor_uuid)); +joinable!(sso_nonce -> organizations (org_uuid)); allow_tables_to_appear_in_same_query!( attachments, diff --git a/src/db/schemas/postgresql/schema.rs b/src/db/schemas/postgresql/schema.rs index 9fd6fd97..1e23b101 100644 --- a/src/db/schemas/postgresql/schema.rs +++ b/src/db/schemas/postgresql/schema.rs @@ -100,11 +100,25 @@ table! { uuid -> Text, name -> Text, billing_email -> Text, + identifier -> Nullable, private_key -> Nullable, public_key -> Nullable, } } +table! { + sso_config (uuid) { + uuid -> Text, + org_uuid -> Text, + use_sso -> Bool, + callback_path -> Text, + signed_out_callback_path -> Text, + authority -> Nullable, + client_id -> Nullable, + client_secret -> Nullable, + } +} + table! { sends (uuid) { uuid -> Text, @@ -203,6 +217,14 @@ table! { } } +table! { + sso_nonce (uuid) { + uuid -> Text, + org_uuid -> Text, + nonce -> Text, + } +} + table! { emergency_access (uuid) { uuid -> Text, @@ -239,6 +261,7 @@ joinable!(users_collections -> users (user_uuid)); joinable!(users_organizations -> organizations (org_uuid)); joinable!(users_organizations -> users (user_uuid)); joinable!(emergency_access -> users (grantor_uuid)); +joinable!(sso_nonce -> organizations (org_uuid)); allow_tables_to_appear_in_same_query!( attachments, diff --git a/src/db/schemas/sqlite/schema.rs b/src/db/schemas/sqlite/schema.rs index 9fd6fd97..1e23b101 100644 --- a/src/db/schemas/sqlite/schema.rs +++ b/src/db/schemas/sqlite/schema.rs @@ -100,11 +100,25 @@ table! { uuid -> Text, name -> Text, billing_email -> Text, + identifier -> Nullable, private_key -> Nullable, public_key -> Nullable, } } +table! { + sso_config (uuid) { + uuid -> Text, + org_uuid -> Text, + use_sso -> Bool, + callback_path -> Text, + signed_out_callback_path -> Text, + authority -> Nullable, + client_id -> Nullable, + client_secret -> Nullable, + } +} + table! { sends (uuid) { uuid -> Text, @@ -203,6 +217,14 @@ table! { } } +table! { + sso_nonce (uuid) { + uuid -> Text, + org_uuid -> Text, + nonce -> Text, + } +} + table! { emergency_access (uuid) { uuid -> Text, @@ -239,6 +261,7 @@ joinable!(users_collections -> users (user_uuid)); joinable!(users_organizations -> organizations (org_uuid)); joinable!(users_organizations -> users (user_uuid)); joinable!(emergency_access -> users (grantor_uuid)); +joinable!(sso_nonce -> organizations (org_uuid)); allow_tables_to_appear_in_same_query!( attachments, diff --git a/web-vault-sso.patch b/web-vault-sso.patch new file mode 100644 index 00000000..6b5476af --- /dev/null +++ b/web-vault-sso.patch @@ -0,0 +1,686 @@ +Submodule jslib contains modified content +diff --git a/jslib/angular/src/components/register.component.ts b/jslib/angular/src/components/register.component.ts +index 53ec3c8..7b49db1 100644 +--- a/jslib/angular/src/components/register.component.ts ++++ b/jslib/angular/src/components/register.component.ts +@@ -24,7 +24,7 @@ export class RegisterComponent { + formPromise: Promise; + masterPasswordScore: number; + referenceData: ReferenceEventRequest; +- showTerms = true; ++ showTerms = false; + acceptPolicies: boolean = false; + + protected successRoute = 'login'; +@@ -35,7 +35,7 @@ export class RegisterComponent { + protected apiService: ApiService, protected stateService: StateService, + protected platformUtilsService: PlatformUtilsService, + protected passwordGenerationService: PasswordGenerationService) { +- this.showTerms = !platformUtilsService.isSelfHost(); ++ this.showTerms = false; + } + + get masterPasswordScoreWidth() { +@@ -69,6 +69,12 @@ export class RegisterComponent { + } + + async submit() { ++ if (typeof crypto.subtle === 'undefined') { ++ this.platformUtilsService.showToast('error', "This browser requires HTTPS to use the web vault", ++ "Check the Vaultwarden wiki for details on how to enable it"); ++ return; ++ } ++ + if (!this.acceptPolicies && this.showTerms) { + this.platformUtilsService.showToast('error', this.i18nService.t('errorOccurred'), + this.i18nService.t('acceptPoliciesError')); +diff --git a/jslib/angular/src/components/sso.component.ts b/jslib/angular/src/components/sso.component.ts +index d4512a1..ad57f69 100644 +--- a/jslib/angular/src/components/sso.component.ts ++++ b/jslib/angular/src/components/sso.component.ts +@@ -19,6 +19,8 @@ import { Utils } from 'jslib-common/misc/utils'; + + import { AuthResult } from 'jslib-common/models/domain/authResult'; + ++import { switchMap } from 'rxjs/operators'; ++ + @Directive() + export class SsoComponent { + identifier: string; +@@ -48,13 +50,19 @@ export class SsoComponent { + + async ngOnInit() { + const queryParamsSub = this.route.queryParams.subscribe(async qParams => { +- if (qParams.code != null && qParams.state != null) { ++ // I have no idea why the qParams is empty here - I've hacked in an alternative very messily, but it works. ++ const workingParams = (new URL(window.location.href)).searchParams; ++ const workingSwap = { ++ code: workingParams.get('code'), ++ state: workingParams.get('state'), ++ }; ++ if (workingSwap.code != null && workingSwap.state != null) { + const codeVerifier = await this.storageService.get(ConstantsService.ssoCodeVerifierKey); + const state = await this.storageService.get(ConstantsService.ssoStateKey); + await this.storageService.remove(ConstantsService.ssoCodeVerifierKey); + await this.storageService.remove(ConstantsService.ssoStateKey); +- if (qParams.code != null && codeVerifier != null && state != null && this.checkState(state, qParams.state)) { +- await this.logIn(qParams.code, codeVerifier, this.getOrgIdentiferFromState(qParams.state)); ++ if (workingSwap.code != null && codeVerifier != null && state != null && this.checkState(state, workingSwap.state)) { ++ await this.logIn(workingSwap.code, codeVerifier, this.getOrgIdentiferFromState(workingSwap.state)); + } + } else if (qParams.clientId != null && qParams.redirectUri != null && qParams.state != null && + qParams.codeChallenge != null) { +@@ -122,7 +130,7 @@ export class SsoComponent { + let authorizeUrl = this.apiService.identityBaseUrl + '/connect/authorize?' + + 'client_id=' + this.clientId + '&redirect_uri=' + encodeURIComponent(this.redirectUri) + '&' + + 'response_type=code&scope=api offline_access&' + +- 'state=' + state + '&code_challenge=' + codeChallenge + '&' + ++ 'state=' + encodeURIComponent(state) + '&code_challenge=' + codeChallenge + '&' + + 'code_challenge_method=S256&response_mode=query&' + + 'domain_hint=' + encodeURIComponent(this.identifier); + +@@ -137,7 +145,7 @@ export class SsoComponent { + private async logIn(code: string, codeVerifier: string, orgIdFromState: string) { + this.loggingIn = true; + try { +- this.formPromise = this.authService.logInSso(code, codeVerifier, this.redirectUri); ++ this.formPromise = this.authService.logInSso(code, codeVerifier, this.redirectUri, orgIdFromState); + const response = await this.formPromise; + if (response.twoFactor) { + if (this.onSuccessfulLoginTwoFactorNavigate != null) { +diff --git a/jslib/common/src/abstractions/api.service.ts b/jslib/common/src/abstractions/api.service.ts +index 67131df..ce498ff 100644 +--- a/jslib/common/src/abstractions/api.service.ts ++++ b/jslib/common/src/abstractions/api.service.ts +@@ -33,6 +33,7 @@ import { KeysRequest } from '../models/request/keysRequest'; + import { OrganizationCreateRequest } from '../models/request/organizationCreateRequest'; + import { OrganizationImportRequest } from '../models/request/organizationImportRequest'; + import { OrganizationKeysRequest } from '../models/request/organizationKeysRequest'; ++import { OrganizationSsoUpdateRequest } from '../models/request/organizationSsoUpdateRequest'; + import { OrganizationTaxInfoUpdateRequest } from '../models/request/organizationTaxInfoUpdateRequest'; + import { OrganizationUpdateRequest } from '../models/request/organizationUpdateRequest'; + import { OrganizationUpgradeRequest } from '../models/request/organizationUpgradeRequest'; +@@ -122,6 +123,7 @@ import { SendAccessResponse } from '../models/response/sendAccessResponse'; + import { SendFileDownloadDataResponse } from '../models/response/sendFileDownloadDataResponse'; + import { SendFileUploadDataResponse } from '../models/response/sendFileUploadDataResponse'; + import { SendResponse } from '../models/response/sendResponse'; ++import { SsoConfigResponse } from '../models/response/ssoConfigResponse'; + import { SubscriptionResponse } from '../models/response/subscriptionResponse'; + import { SyncResponse } from '../models/response/syncResponse'; + import { TaxInfoResponse } from '../models/response/taxInfoResponse'; +@@ -360,6 +362,8 @@ export abstract class ApiService { + getOrganizationTaxInfo: (id: string) => Promise; + postOrganization: (request: OrganizationCreateRequest) => Promise; + putOrganization: (id: string, request: OrganizationUpdateRequest) => Promise; ++ getSsoConfig: (id: string) => Promise; ++ putOrganizationSso: (id: string, request: OrganizationSsoUpdateRequest) => Promise; + putOrganizationTaxInfo: (id: string, request: OrganizationTaxInfoUpdateRequest) => Promise; + postLeaveOrganization: (id: string) => Promise; + postOrganizationLicense: (data: FormData) => Promise; +diff --git a/jslib/common/src/abstractions/auth.service.ts b/jslib/common/src/abstractions/auth.service.ts +index ac7ef04..5b1b774 100644 +--- a/jslib/common/src/abstractions/auth.service.ts ++++ b/jslib/common/src/abstractions/auth.service.ts +@@ -15,7 +15,7 @@ export abstract class AuthService { + selectedTwoFactorProviderType: TwoFactorProviderType; + + logIn: (email: string, masterPassword: string) => Promise; +- logInSso: (code: string, codeVerifier: string, redirectUrl: string) => Promise; ++ logInSso: (code: string, codeVerifier: string, redirectUrl: string, orgIdentifier: string) => Promise; + logInApiKey: (clientId: string, clientSecret: string) => Promise; + logInTwoFactor: (twoFactorProvider: TwoFactorProviderType, twoFactorToken: string, + remember?: boolean) => Promise; +diff --git a/jslib/common/src/models/request/organizationSsoUpdateRequest.ts b/jslib/common/src/models/request/organizationSsoUpdateRequest.ts +new file mode 100644 +index 0000000..7075aec +--- /dev/null ++++ b/jslib/common/src/models/request/organizationSsoUpdateRequest.ts +@@ -0,0 +1,8 @@ ++export class OrganizationSsoUpdateRequest { ++ useSso: boolean; ++ callbackPath: string; ++ signedOutCallbackPath: string; ++ authority: string; ++ clientId: string; ++ clientSecret: string; ++} +diff --git a/jslib/common/src/models/request/tokenRequest.ts b/jslib/common/src/models/request/tokenRequest.ts +index 7578012..964364f 100644 +--- a/jslib/common/src/models/request/tokenRequest.ts ++++ b/jslib/common/src/models/request/tokenRequest.ts +@@ -14,9 +14,10 @@ export class TokenRequest { + provider: TwoFactorProviderType; + remember: boolean; + device?: DeviceRequest; ++ orgIdentifier?: string; + + constructor(credentials: string[], codes: string[], clientIdClientSecret: string[], provider: TwoFactorProviderType, +- token: string, remember: boolean, device?: DeviceRequest) { ++ token: string, remember: boolean, device?: DeviceRequest, orgIdentifier?: string) { + if (credentials != null && credentials.length > 1) { + this.email = credentials[0]; + this.masterPasswordHash = credentials[1]; +@@ -28,6 +29,9 @@ export class TokenRequest { + this.clientId = clientIdClientSecret[0]; + this.clientSecret = clientIdClientSecret[1]; + } ++ if (orgIdentifier && orgIdentifier !== '') { ++ this.orgIdentifier = orgIdentifier; ++ } + this.token = token; + this.provider = provider; + this.remember = remember; +@@ -53,6 +57,7 @@ export class TokenRequest { + obj.code = this.code; + obj.code_verifier = this.codeVerifier; + obj.redirect_uri = this.redirectUri; ++ obj.org_identifier = this.orgIdentifier; + } else { + throw new Error('must provide credentials or codes'); + } +diff --git a/jslib/common/src/services/api.service.ts b/jslib/common/src/services/api.service.ts +index 51c1c14..b615672 100644 +--- a/jslib/common/src/services/api.service.ts ++++ b/jslib/common/src/services/api.service.ts +@@ -37,6 +37,7 @@ import { KeysRequest } from '../models/request/keysRequest'; + import { OrganizationCreateRequest } from '../models/request/organizationCreateRequest'; + import { OrganizationImportRequest } from '../models/request/organizationImportRequest'; + import { OrganizationKeysRequest } from '../models/request/organizationKeysRequest'; ++import { OrganizationSsoUpdateRequest } from '../models/request/organizationSsoUpdateRequest'; + import { OrganizationTaxInfoUpdateRequest } from '../models/request/organizationTaxInfoUpdateRequest'; + import { OrganizationUpdateRequest } from '../models/request/organizationUpdateRequest'; + import { OrganizationUpgradeRequest } from '../models/request/organizationUpgradeRequest'; +@@ -128,6 +129,7 @@ import { SendAccessResponse } from '../models/response/sendAccessResponse'; + import { SendFileDownloadDataResponse } from '../models/response/sendFileDownloadDataResponse'; + import { SendFileUploadDataResponse } from '../models/response/sendFileUploadDataResponse'; + import { SendResponse } from '../models/response/sendResponse'; ++import { SsoConfigResponse } from '../models/response/ssoConfigResponse'; + import { SubscriptionResponse } from '../models/response/subscriptionResponse'; + import { SyncResponse } from '../models/response/syncResponse'; + import { TaxInfoResponse } from '../models/response/taxInfoResponse'; +@@ -1158,6 +1160,16 @@ export class ApiService implements ApiServiceAbstraction { + return new OrganizationResponse(r); + } + ++ async getSsoConfig(id: string): Promise { ++ const r = await this.send('GET', '/organizations/' + id + '/sso', null, true, true); ++ return new SsoConfigResponse(r); ++ } ++ ++ async putOrganizationSso(id: string, request: OrganizationSsoUpdateRequest): Promise { ++ const r = await this.send('PUT', '/organizations/' + id + '/sso', request, true, false); ++ return new SsoConfigResponse(r); ++ } ++ + async putOrganizationTaxInfo(id: string, request: OrganizationTaxInfoUpdateRequest): Promise { + return this.send('PUT', '/organizations/' + id + '/tax', request, true, false); + } +diff --git a/jslib/common/src/services/auth.service.ts b/jslib/common/src/services/auth.service.ts +index 6536a94..6f4899c 100644 +--- a/jslib/common/src/services/auth.service.ts ++++ b/jslib/common/src/services/auth.service.ts +@@ -130,10 +130,10 @@ export class AuthService implements AuthServiceAbstraction { + key, null, null, null); + } + +- async logInSso(code: string, codeVerifier: string, redirectUrl: string): Promise { ++ async logInSso(code: string, codeVerifier: string, redirectUrl: string, orgIdentifier: string): Promise { + this.selectedTwoFactorProviderType = null; + return await this.logInHelper(null, null, null, code, codeVerifier, redirectUrl, null, null, +- null, null, null, null); ++ null, null, null, null, orgIdentifier); + } + + async logInApiKey(clientId: string, clientSecret: string): Promise { +@@ -272,7 +272,7 @@ export class AuthService implements AuthServiceAbstraction { + + private async logInHelper(email: string, hashedPassword: string, localHashedPassword: string, code: string, + codeVerifier: string, redirectUrl: string, clientId: string, clientSecret: string, key: SymmetricCryptoKey, +- twoFactorProvider?: TwoFactorProviderType, twoFactorToken?: string, remember?: boolean): Promise { ++ twoFactorProvider?: TwoFactorProviderType, twoFactorToken?: string, remember?: boolean, orgIdentifier?: string): Promise { + const storedTwoFactorToken = await this.tokenService.getTwoFactorToken(email); + const appId = await this.appIdService.getAppId(); + const deviceRequest = new DeviceRequest(appId, this.platformUtilsService); +@@ -300,13 +300,13 @@ export class AuthService implements AuthServiceAbstraction { + let request: TokenRequest; + if (twoFactorToken != null && twoFactorProvider != null) { + request = new TokenRequest(emailPassword, codeCodeVerifier, clientIdClientSecret, twoFactorProvider, +- twoFactorToken, remember, deviceRequest); ++ twoFactorToken, remember, deviceRequest, orgIdentifier); + } else if (storedTwoFactorToken != null) { + request = new TokenRequest(emailPassword, codeCodeVerifier, clientIdClientSecret, TwoFactorProviderType.Remember, +- storedTwoFactorToken, false, deviceRequest); ++ storedTwoFactorToken, false, deviceRequest, orgIdentifier); + } else { + request = new TokenRequest(emailPassword, codeCodeVerifier, clientIdClientSecret, null, +- null, false, deviceRequest); ++ null, false, deviceRequest, orgIdentifier); + } + + const response = await this.apiService.postIdentityToken(request); +diff --git a/src/404.html b/src/404.html +index eba36375..cb8883ec 100644 +--- a/src/404.html ++++ b/src/404.html +@@ -41,10 +41,10 @@ + +

+

You can return to the web vault, check our status page +- or contact us.

++ or contact us.

+ + + + +diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts +index bee8416f..dad32467 100644 +--- a/src/app/app-routing.module.ts ++++ b/src/app/app-routing.module.ts +@@ -33,6 +33,7 @@ import { AccountComponent as OrgAccountComponent } from './organizations/setting + import { OrganizationBillingComponent } from './organizations/settings/organization-billing.component'; + import { OrganizationSubscriptionComponent } from './organizations/settings/organization-subscription.component'; + import { SettingsComponent as OrgSettingsComponent } from './organizations/settings/settings.component'; ++import { SsoComponent as OrgSsoComponent } from './organizations/settings/sso.component'; + import { + TwoFactorSetupComponent as OrgTwoFactorSetupComponent, + } from './organizations/settings/two-factor-setup.component'; +@@ -412,6 +413,7 @@ const routes: Routes = [ + children: [ + { path: '', pathMatch: 'full', redirectTo: 'account' }, + { path: 'account', component: OrgAccountComponent, data: { titleId: 'myOrganization' } }, ++ { path: 'sso', component: OrgSsoComponent, data: { titleId: 'sso' } }, + { path: 'two-factor', component: OrgTwoFactorSetupComponent, data: { titleId: 'twoStepLogin' } }, + { + path: 'billing', +diff --git a/src/app/app.component.ts b/src/app/app.component.ts +index 2922cf09..8f2be1ad 100644 +--- a/src/app/app.component.ts ++++ b/src/app/app.component.ts +@@ -146,6 +146,10 @@ export class AppComponent implements OnDestroy, OnInit { + } + break; + case 'showToast': ++ if (typeof message.text === "string" && typeof crypto.subtle === 'undefined') { ++ message.title="This browser requires HTTPS to use the web vault"; ++ message.text="Check the Vaultwarden wiki for details on how to enable it"; ++ } + this.showToast(message); + break; + case 'setFullWidth': +diff --git a/src/app/app.module.ts b/src/app/app.module.ts +index bafc22a0..938db7ee 100644 +--- a/src/app/app.module.ts ++++ b/src/app/app.module.ts +@@ -67,6 +67,7 @@ import { DownloadLicenseComponent } from './organizations/settings/download-lice + import { OrganizationBillingComponent } from './organizations/settings/organization-billing.component'; + import { OrganizationSubscriptionComponent } from './organizations/settings/organization-subscription.component'; + import { SettingsComponent as OrgSettingComponent } from './organizations/settings/settings.component'; ++import { SsoComponent as OrgSsoComponent } from './organizations/settings/sso.component'; + import { + TwoFactorSetupComponent as OrgTwoFactorSetupComponent, + } from './organizations/settings/two-factor-setup.component'; +@@ -347,6 +348,7 @@ registerLocaleData(localeZhTw, 'zh-TW'); + NavbarComponent, + OptionsComponent, + OrgAccountComponent, ++ OrgSsoComponent, + OrgAddEditComponent, + OrganizationBillingComponent, + OrganizationPlansComponent, +diff --git a/src/app/layouts/footer.component.html b/src/app/layouts/footer.component.html +index b001b9e3..c1bd2ac8 100644 +--- a/src/app/layouts/footer.component.html ++++ b/src/app/layouts/footer.component.html +@@ -1,7 +1,7 @@ +