diff --git a/.dockerignore b/.dockerignore index 69f51d2a24..e7ae781fcd 100644 --- a/.dockerignore +++ b/.dockerignore @@ -24,4 +24,4 @@ hooks tools # Web vault -web-vault \ No newline at end of file +#web-vault diff --git a/Cargo.lock b/Cargo.lock index 0e18e6f6b7..4fcfcb508a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -812,8 +812,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "418d37c8b1d42553c93648be529cb70f920d3baf8ef469b74b9638df426e0b4c" dependencies = [ "cfg-if 1.0.0", + "js-sys", "libc", "wasi 0.10.0+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -1031,6 +1033,19 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87c48c02e0dc5e3b849a2041db3029fd066650f8f717c07bf8ed78ccb895cac" +dependencies = [ + "http", + "hyper 0.14.16", + "rustls 0.20.2", + "tokio", + "tokio-rustls", +] + [[package]] name = "hyper-sync-rustls" version = "0.3.0-rc.17" @@ -1038,9 +1053,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cb014c4ea00486e2b62860b5e15229d37516d4924177218beafbf46583de3ab" dependencies = [ "hyper 0.10.16", - "rustls", - "webpki", - "webpki-roots", + "rustls 0.17.0", + "webpki 0.21.4", + "webpki-roots 0.19.0", ] [[package]] @@ -1124,6 +1139,15 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68f2d64f2edebec4ce84ad108148e67e1064789bee435edc5b60ad398714a3a9" +[[package]] +name = "itertools" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "284f18f85651fe11e8a991b2adb42cb078325c996ed026d994719efcfca1d54b" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "0.4.8" @@ -1590,6 +1614,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f93ab6289c7b344a8a9f60f88d80aa20032336fe78da341afc91c8a2341fc75f" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-derive" version = "0.3.3" @@ -1630,6 +1665,26 @@ dependencies = [ "libc", ] +[[package]] +name = "oauth2" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80e47cfc4c0a1a519d9a025ebfbac3a2439d1b5cdf397d72dcb79b11d9920dab" +dependencies = [ + "base64 0.13.0", + "chrono", + "getrandom 0.2.4", + "http", + "rand 0.8.4", + "reqwest", + "serde", + "serde_json", + "serde_path_to_error", + "sha2", + "thiserror", + "url 2.2.2", +] + [[package]] name = "object" version = "0.27.1" @@ -1657,6 +1712,31 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" +[[package]] +name = "openidconnect" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6db0c030c3036f53c7108668641151b244358d221303a17985b07ac9bb60091" +dependencies = [ + "base64 0.13.0", + "chrono", + "http", + "itertools", + "log 0.4.14", + "num-bigint 0.4.3", + "oauth2", + "rand 0.8.4", + "ring", + "serde", + "serde-value", + "serde_derive", + "serde_json", + "serde_path_to_error", + "thiserror", + "untrusted", + "url 2.2.2", +] + [[package]] name = "openssl" version = "0.10.38" @@ -1700,6 +1780,15 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "ordered-float" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3305af35278dd29f46fcdd139e0b1fbfae2153f0e5928b39b035542dd31e37b7" +dependencies = [ + "num-traits", +] + [[package]] name = "owning_ref" version = "0.3.3" @@ -2294,6 +2383,7 @@ dependencies = [ "http", "http-body", "hyper 0.14.16", + "hyper-rustls", "hyper-tls", "ipnet", "js-sys", @@ -2304,11 +2394,14 @@ dependencies = [ "percent-encoding 2.1.0", "pin-project-lite", "proc-macro-hack", + "rustls 0.20.2", + "rustls-pemfile", "serde", "serde_json", "serde_urlencoded", "tokio", "tokio-native-tls", + "tokio-rustls", "tokio-socks", "tokio-util", "trust-dns-resolver", @@ -2316,6 +2409,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "webpki-roots 0.22.2", "winreg 0.7.0", ] @@ -2420,7 +2514,7 @@ dependencies = [ "indexmap", "pear", "percent-encoding 1.0.1", - "rustls", + "rustls 0.17.0", "smallvec 1.8.0", "state", "time 0.2.27", @@ -2451,8 +2545,29 @@ dependencies = [ "base64 0.11.0", "log 0.4.14", "ring", - "sct", - "webpki", + "sct 0.6.1", + "webpki 0.21.4", +] + +[[package]] +name = "rustls" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d37e5e2290f3e040b594b1a9e04377c2c671f1a1cfd9bfdef82106ac1c113f84" +dependencies = [ + "log 0.4.14", + "ring", + "sct 0.7.0", + "webpki 0.22.0", +] + +[[package]] +name = "rustls-pemfile" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eebeaeb360c87bfb72e84abdb3447159c0eaececf1bef2aecd65a8be949d1c9" +dependencies = [ + "base64 0.13.0", ] [[package]] @@ -2511,6 +2626,16 @@ dependencies = [ "untrusted", ] +[[package]] +name = "sct" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d53dcdb7c9f8158937a7981b48accfd39a43af418591a5d008c7b22b5e1b7ca4" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "security-framework" version = "2.6.0" @@ -2558,6 +2683,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-value" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a65a7291a8a568adcae4c10a677ebcedbc6c9cec91c054dee2ce40b0e3290eb" +dependencies = [ + "ordered-float", + "serde", +] + [[package]] name = "serde_cbor" version = "0.11.2" @@ -2590,6 +2725,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7868ad3b8196a8a0aea99a8220b124278ee5320a55e4fde97794b6f85b1a377" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2662,7 +2806,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "692ca13de57ce0613a363c8c2f1de925adebc81b04c923ac60c5488bb44abe4b" dependencies = [ "chrono", - "num-bigint", + "num-bigint 0.2.6", "num-traits", ] @@ -2999,6 +3143,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a27d5f2b839802bd8267fa19b0530f5a08b9c08cd417976be2a65d130fe1c11b" +dependencies = [ + "rustls 0.20.2", + "tokio", + "webpki 0.22.0", +] + [[package]] name = "tokio-socks" version = "0.5.1" @@ -3322,6 +3477,7 @@ dependencies = [ "num-derive", "num-traits", "once_cell", + "openidconnect", "openssl", "parity-ws", "paste", @@ -3503,13 +3659,32 @@ dependencies = [ "untrusted", ] +[[package]] +name = "webpki" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f095d78192e208183081cc07bc5515ef55216397af48b873e5edcd72637fa1bd" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "webpki-roots" version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8eff4b7516a57307f9349c64bf34caa34b940b66fed4b2fb3136cb7386e5739" dependencies = [ - "webpki", + "webpki 0.21.4", +] + +[[package]] +name = "webpki-roots" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "552ceb903e957524388c4d3475725ff2c8b7960922063af6ce53c9a43da07449" +dependencies = [ + "webpki 0.22.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 46a7ca07fe..9b6fc12aa6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -138,6 +138,7 @@ pico-args = "0.4.2" backtrace = "0.3.64" # Macro ident concatenation +openidconnect = "2.0.1" paste = "1.0.6" governor = "0.4.1" 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 0000000000..ade3aeedf3 --- /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 0000000000..e42102144a --- /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 c747e9aa03..d66435b24a 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 0000000000..ade3aeedf3 --- /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 0000000000..e42102144a --- /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 0000000000..ade3aeedf3 --- /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 0000000000..e42102144a --- /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 fa79c39c33..def05cb4cd 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -24,6 +24,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, @@ -76,6 +78,18 @@ struct OrgData { struct OrganizationUpdateData { BillingEmail: String, Name: String, + Identifier: Option, +} + +#[derive(Deserialize, Debug)] +#[allow(non_snake_case)] +struct OrganizationSsoUpdateData { + UseSso: bool, + CallbackPath: String, + SignedOutCallbackPath: String, + Authority: Option, + ClientId: Option, + ClientSecret: Option, } #[derive(Deserialize, Debug)] @@ -118,6 +132,7 @@ fn create_organization(headers: Headers, data: JsonUpcase, conn: DbConn 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; @@ -127,6 +142,7 @@ fn create_organization(headers: Headers, data: JsonUpcase, conn: DbConn org.save(&conn)?; user_org.save(&conn)?; + sso_config.save(&conn)?; collection.save(&conn)?; Ok(Json(org.to_json())) @@ -184,7 +200,9 @@ fn leave_organization(org_id: String, headers: Headers, conn: DbConn) -> EmptyRe #[get("/organizations/")] fn get_organization(org_id: String, _headers: OwnerHeaders, conn: DbConn) -> JsonResult { match Organization::find_by_uuid(&org_id, &conn) { - Some(organization) => Ok(Json(organization.to_json())), + Some(organization) => { + Ok(Json(organization.to_json())) + }, None => err!("Can't find organization details"), } } @@ -215,11 +233,48 @@ fn post_organization( org.name = data.Name; org.billing_email = data.BillingEmail; + org.identifier = data.Identifier; org.save(&conn)?; Ok(Json(org.to_json())) } +#[get("/organizations//sso")] +fn get_organization_sso(org_id: String, _headers: OwnerHeaders, conn: DbConn) -> JsonResult { + match SsoConfig::find_by_org(&org_id, &conn) { + Some(sso_config) => Ok(Json(sso_config.to_json())), + None => err!("Can't find organization sso config"), + } +} + +#[put("/organizations//sso", data = "")] +fn put_organization_sso( + org_id: String, + _headers: OwnerHeaders, + data: JsonUpcase, + conn: DbConn, +) -> JsonResult { + let data: OrganizationSsoUpdateData = data.into_inner().data; + + let mut sso_config = match SsoConfig::find_by_org(&org_id, &conn) { + Some(sso_config) => sso_config, + None => { + let sso_config = SsoConfig::new(org_id); + sso_config + }, + }; + + sso_config.use_sso = data.UseSso; + sso_config.callback_path = data.CallbackPath; + sso_config.signed_out_callback_path = data.SignedOutCallbackPath; + sso_config.authority = data.Authority; + sso_config.client_id = data.ClientId; + sso_config.client_secret = data.ClientSecret; + + sso_config.save(&conn)?; + Ok(Json(sso_config.to_json())) +} + // GET /api/collections?writeOnly=false #[get("/collections")] fn get_user_collections(headers: Headers, conn: DbConn) -> Json { diff --git a/src/api/identity.rs b/src/api/identity.rs index 0adc542ff7..f041216c5a 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -1,11 +1,14 @@ use chrono::Utc; use num_traits::FromPrimitive; use rocket::{ + http::Status, request::{Form, FormItems, FromForm}, + response::Redirect, Route, }; use rocket_contrib::json::Json; use serde_json::Value; +use std::iter::FromIterator; use crate::{ api::{ @@ -19,7 +22,7 @@ use crate::{ }; pub fn routes() -> Vec { - routes![login] + routes![login, prevalidate, authorize] } #[post("/connect/token", data = "")] @@ -43,6 +46,13 @@ fn login(data: Form, conn: DbConn, ip: ClientIp) -> JsonResult { _password_login(data, conn, &ip) } + "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) + } "client_credentials" => { _check_is_some(&data.client_id, "client_id cannot be blank")?; _check_is_some(&data.client_secret, "client_secret cannot be blank")?; @@ -86,6 +96,85 @@ fn _refresh_login(data: ConnectData, conn: DbConn) -> JsonResult { }))) } +#[derive(Debug, Serialize, Deserialize)] +struct TokenPayload { + exp: i64, + email: String, + nonce: String, +} + +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).unwrap(); + let sso_config = SsoConfig::find_by_org(&organization.uuid, &conn).unwrap(); + + let (access_token, refresh_token) = match get_auth_code_access_token(&code, &sso_config) { + Ok((access_token, refresh_token)) => (access_token, refresh_token), + Err(err) => err!(err), + }; + + let token = jsonwebtoken::dangerous_insecure_decode::(access_token.as_str()).unwrap().claims; + let nonce = token.nonce; + + match SsoNonce::find_by_org_and_nonce(&organization.uuid, &nonce, &conn) { + Some(sso_nonce) => { + match sso_nonce.delete(&conn) { + Ok(_) => { + let expiry = token.exp; + let user_email = token.email; + let now = Utc::now().naive_utc(); + + // COMMON + let user = User::find_by_mail(&user_email, &conn).unwrap(); + + let (mut device, new_device) = get_device(&data, &conn, &user); + + let twofactor_token = twofactor_auth(&user.uuid, &data, &mut device, ip, &conn)?; + + 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) { + 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)?; + + let mut result = json!({ + "access_token": access_token, + "expires_in": expiry - now.timestamp(), + "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); + } + + Ok(Json(result)) + }, + Err(_) => err!("Failed to delete nonce"), + } + }, + None => { + err!("Invalid nonce") + } + } +} + fn _password_login(data: ConnectData, conn: DbConn, ip: &ClientIp) -> JsonResult { // Validate scope let scope = data.scope.as_ref().unwrap(); @@ -115,6 +204,15 @@ fn _password_login(data: ConnectData, conn: DbConn, ip: &ClientIp) -> JsonResult 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); + if user_orgs.len() >= 1 && 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() { @@ -480,6 +578,10 @@ struct ConnectData { two_factor_provider: Option, two_factor_token: Option, two_factor_remember: Option, + + // Needed for authorization code + code: Option, + org_identifier: Option, } impl<'f> FromForm<'f> for ConnectData { @@ -507,10 +609,11 @@ impl<'f> FromForm<'f> for ConnectData { "twofactorprovider" => form.two_factor_provider = value.parse().ok(), "twofactortoken" => form.two_factor_token = Some(value), "twofactorremember" => form.two_factor_remember = value.parse().ok(), + "code" => form.code = Some(value), + "orgidentifier" => form.org_identifier = Some(value), key => warn!("Detected unexpected parameter during login: {}", key), } } - Ok(form) } } @@ -521,3 +624,136 @@ fn _check_is_some(value: &Option, msg: &str) -> EmptyResult { } 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)] +fn prevalidate(domainHint: String, conn: DbConn) -> JsonResult { + let empty_result = json!({}); + match Organization::find_by_identifier(&domainHint, &conn) { + Some(organization) => { + let sso_config = SsoConfig::find_by_org(&organization.uuid, &conn); + match sso_config { + 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 == "" { + return err_code!("No Organization Identifier Provided", Status::BadRequest.code); + } + + Ok(Json(empty_result)) + }, + None => { + return err_code!("No matching organization found", Status::BadRequest.code); + } + } +} + +use openidconnect::core::{ + CoreProviderMetadata, CoreClient, + CoreResponseType, +}; +use openidconnect::reqwest::http_client; +use openidconnect::{ + AuthenticationFlow, AuthorizationCode, ClientId, ClientSecret, + CsrfToken, IssuerUrl, Nonce, RedirectUrl, + Scope, OAuth2TokenResponse, +}; + +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"))?; + let provider_metadata = match CoreProviderMetadata::discover(&issuer_url, http_client) { + 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"))?); + return Ok(client); +} + +#[get("/connect/authorize?&")] +fn authorize( + domain_hint: String, + state: String, + conn: DbConn, +) -> ApiResult { + let organization = Organization::find_by_identifier(&domain_hint, &conn).unwrap(); + let sso_config = SsoConfig::find_by_org(&organization.uuid, &conn).unwrap(); + match get_client_from_sso_config(&sso_config) { + 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)?; + + // 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().clone(); + 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())); + + return Ok(Redirect::to(authorize_url.to_string())); + }, + Err(_err) => err!("Unable to find client from identifier"), + } +} + +fn get_auth_code_access_token ( + code: &str, + sso_config: &SsoConfig, +) -> Result<(String, String), &'static str> { + let oidc_code = AuthorizationCode::new(String::from(code)); + match get_client_from_sso_config(sso_config) { + Ok(client) => { + match client.exchange_code(oidc_code).request(http_client) { + Ok(token_response) => { + let access_token = token_response.access_token().secret().to_string(); + let refresh_token = token_response.refresh_token().unwrap().secret().to_string(); + + Ok((access_token, refresh_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 251511da1b..890d23fba7 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -11,6 +11,8 @@ mod send; mod two_factor; mod two_factor_incomplete; mod user; +mod sso_nonce; +mod sso_config; pub use self::attachment::Attachment; pub use self::cipher::Cipher; @@ -25,3 +27,5 @@ pub use self::send::{Send, SendType}; pub use self::two_factor::{TwoFactor, TwoFactorType}; pub use self::two_factor_incomplete::TwoFactorIncomplete; pub use self::user::{Invitation, User, UserStampException}; +pub use self::sso_nonce::SsoNonce; +pub use self::sso_config::SsoConfig; diff --git a/src/db/models/org_policy.rs b/src/db/models/org_policy.rs index 7c6cefd3ed..b70104ab93 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 currently supported. + RequireSso = 4, PersonalOwnership = 5, DisableSend = 6, SendOptions = 7, diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index 67dd5357d9..9392cbb219 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, } @@ -131,13 +132,14 @@ impl Organization { billing_email, private_key, public_key, + identifier: None, } } 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 "MaxCollections": 10, // The value doesn't matter, we don't check server-side @@ -148,7 +150,6 @@ impl Organization { "UseGroups": false, // not supported by us "UseTotp": true, "UsePolicies": true, - "UseSso": false, // We do not support SSO "SelfHost": true, "UseApi": false, // not supported by us "HasPublicAndPrivateKeys": self.private_key.is_some() && self.public_key.is_some(), @@ -254,6 +255,15 @@ impl Organization { }} } + pub 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 fn get_all(conn: &DbConn) -> Vec { db_run! { conn: { organizations::table.load::(conn).expect("Error loading organizations").from_db() @@ -283,8 +293,8 @@ impl UserOrganization { "SelfHost": true, "HasPublicAndPrivateKeys": org.private_key.is_some() && org.public_key.is_some(), "ResetPasswordEnrolled": false, // not supported by us - "SsoBound": false, // We do not support SSO - "UseSso": false, // We do not support SSO + "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. diff --git a/src/db/models/sso_config.rs b/src/db/models/sso_config.rs new file mode 100644 index 0000000000..61e631139d --- /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 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 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 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 0000000000..26b96ec032 --- /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 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 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 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 61234a1695..efc87e1135 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, @@ -238,6 +260,7 @@ joinable!(users_collections -> collections (collection_uuid)); joinable!(users_collections -> users (user_uuid)); joinable!(users_organizations -> organizations (org_uuid)); joinable!(users_organizations -> users (user_uuid)); +joinable!(sso_nonce -> organizations (org_uuid)); joinable!(emergency_access -> users (grantor_uuid)); allow_tables_to_appear_in_same_query!( diff --git a/src/db/schemas/postgresql/schema.rs b/src/db/schemas/postgresql/schema.rs index 855b4fbcac..ba61806509 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, @@ -238,6 +260,7 @@ joinable!(users_collections -> collections (collection_uuid)); joinable!(users_collections -> users (user_uuid)); joinable!(users_organizations -> organizations (org_uuid)); joinable!(users_organizations -> users (user_uuid)); +joinable!(sso_nonce -> organizations (org_uuid)); joinable!(emergency_access -> users (grantor_uuid)); allow_tables_to_appear_in_same_query!( diff --git a/src/db/schemas/sqlite/schema.rs b/src/db/schemas/sqlite/schema.rs index 855b4fbcac..ba61806509 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, @@ -238,6 +260,7 @@ joinable!(users_collections -> collections (collection_uuid)); joinable!(users_collections -> users (user_uuid)); joinable!(users_organizations -> organizations (org_uuid)); joinable!(users_organizations -> users (user_uuid)); +joinable!(sso_nonce -> organizations (org_uuid)); joinable!(emergency_access -> users (grantor_uuid)); allow_tables_to_appear_in_same_query!( diff --git a/web-vault-sso.patch b/web-vault-sso.patch new file mode 100644 index 0000000000..6a0905f41f --- /dev/null +++ b/web-vault-sso.patch @@ -0,0 +1,706 @@ +Submodule jslib contains modified content +diff --git a/jslib/angular/src/components/register.component.ts b/jslib/angular/src/components/register.component.ts +index fd91af29..abcfd62c 100644 +--- a/jslib/angular/src/components/register.component.ts ++++ b/jslib/angular/src/components/register.component.ts +@@ -30,7 +30,7 @@ export class RegisterComponent extends CaptchaProtectedComponent implements OnIn + formPromise: Promise; + masterPasswordScore: number; + referenceData: ReferenceEventRequest; +- showTerms = true; ++ showTerms = false; + acceptPolicies: boolean = false; + + protected successRoute = 'login'; +@@ -43,7 +43,7 @@ export class RegisterComponent extends CaptchaProtectedComponent implements OnIn + protected passwordGenerationService: PasswordGenerationService, environmentService: EnvironmentService, + protected logService: LogService) { + super(environmentService, i18nService, platformUtilsService); +- this.showTerms = !platformUtilsService.isSelfHost(); ++ this.showTerms = false; + } + + async ngOnInit() { +@@ -81,6 +81,12 @@ export class RegisterComponent extends CaptchaProtectedComponent implements OnIn + } + + 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 1ab8e2f4..7e74fbd7 100644 +--- a/jslib/angular/src/components/sso.component.ts ++++ b/jslib/angular/src/components/sso.component.ts +@@ -23,6 +23,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; +@@ -54,13 +56,19 @@ export class SsoComponent { + + async ngOnInit() { + this.route.queryParams.pipe(first()).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.getOrgIdentifierFromState(qParams.state)); ++ if (workingSwap.code != null && codeVerifier != null && state != null && this.checkState(state, workingSwap.state)) { ++ await this.logIn(workingSwap.code, codeVerifier, this.getOrgIdentifierFromState(workingSwap.state)); + } + } else if (qParams.clientId != null && qParams.redirectUri != null && qParams.state != null && + qParams.codeChallenge != null) { +@@ -125,7 +133,7 @@ export class SsoComponent { + let authorizeUrl = this.environmentService.getIdentityUrl() + '/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); + +diff --git a/jslib/common/src/abstractions/api.service.ts b/jslib/common/src/abstractions/api.service.ts +index 1c6aa0ef..aab45eeb 100644 +--- a/jslib/common/src/abstractions/api.service.ts ++++ b/jslib/common/src/abstractions/api.service.ts +@@ -38,6 +38,7 @@ import { OrganizationSsoRequest } from '../models/request/organization/organizat + 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 { OrganizationSubscriptionUpdateRequest } from '../models/request/organizationSubscriptionUpdateRequest'; + import { OrganizationTaxInfoUpdateRequest } from '../models/request/organizationTaxInfoUpdateRequest'; + import { OrganizationUpdateRequest } from '../models/request/organizationUpdateRequest'; +@@ -148,6 +149,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'; +@@ -386,6 +388,8 @@ export abstract class ApiService { + getOrganizationSso: (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/models/request/organizationSsoUpdateRequest.ts b/jslib/common/src/models/request/organizationSsoUpdateRequest.ts +new file mode 100644 +index 00000000..7075aecc +--- /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 41797eb0..26206356 100644 +--- a/jslib/common/src/models/request/tokenRequest.ts ++++ b/jslib/common/src/models/request/tokenRequest.ts +@@ -14,9 +14,10 @@ export class TokenRequest implements CaptchaProtectedRequest { + clientId: string; + clientSecret: string; + device?: DeviceRequest; ++ orgId?: string + + constructor(credentials: string[], codes: string[], clientIdClientSecret: string[], public provider: TwoFactorProviderType, +- public token: string, public remember: boolean, public captchaResponse: string, device?: DeviceRequest) { ++ public token: string, public remember: boolean, public captchaResponse: string, device?: DeviceRequest, orgId?: string) { + if (credentials != null && credentials.length > 1) { + this.email = credentials[0]; + this.masterPasswordHash = credentials[1]; +@@ -28,6 +29,9 @@ export class TokenRequest implements CaptchaProtectedRequest { + this.clientId = clientIdClientSecret[0]; + this.clientSecret = clientIdClientSecret[1]; + } ++ if (orgId && orgId !== '') { ++ this.orgId = orgId; ++ } + this.device = device != null ? device : null; + } + +@@ -50,6 +54,7 @@ export class TokenRequest implements CaptchaProtectedRequest { + obj.code = this.code; + obj.code_verifier = this.codeVerifier; + obj.redirect_uri = this.redirectUri; ++ obj.org_identifier = this.orgId; + } else { + throw new Error('must provide credentials or codes'); + } +diff --git a/jslib/common/src/models/response/ssoConfigResponse.ts b/jslib/common/src/models/response/ssoConfigResponse.ts +new file mode 100644 +index 00000000..9c72dd33 +--- /dev/null ++++ b/jslib/common/src/models/response/ssoConfigResponse.ts +@@ -0,0 +1,22 @@ ++import { BaseResponse } from './baseResponse'; ++ ++export class SsoConfigResponse extends BaseResponse { ++ id: string; ++ useSso: boolean; ++ callbackPath: string; ++ signedOutCallbackPath: string; ++ authority: string; ++ clientId: string; ++ clientSecret: string; ++ ++ constructor(response: any) { ++ super(response); ++ this.id = this.getResponseProperty('Id'); ++ this.useSso = this.getResponseProperty('UseSso'); ++ this.callbackPath = this.getResponseProperty('CallbackPath'); ++ this.signedOutCallbackPath = this.getResponseProperty('SignedOutCallbackPath'); ++ this.authority = this.getResponseProperty('Authority'); ++ this.clientId = this.getResponseProperty('ClientId'); ++ this.clientSecret = this.getResponseProperty('ClientSecret'); ++ } ++} +diff --git a/jslib/common/src/services/api.service.ts b/jslib/common/src/services/api.service.ts +index 46fdc139..16140f6c 100644 +--- a/jslib/common/src/services/api.service.ts ++++ b/jslib/common/src/services/api.service.ts +@@ -39,6 +39,7 @@ import { OrganizationSsoRequest } from '../models/request/organization/organizat + 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 { OrganizationSubscriptionUpdateRequest } from '../models/request/organizationSubscriptionUpdateRequest'; + import { OrganizationTaxInfoUpdateRequest } from '../models/request/organizationTaxInfoUpdateRequest'; + import { OrganizationUpdateRequest } from '../models/request/organizationUpdateRequest'; +@@ -154,6 +155,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'; +@@ -1187,6 +1189,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 e4f670d7..d96f78cd 100644 +--- a/jslib/common/src/services/auth.service.ts ++++ b/jslib/common/src/services/auth.service.ts +@@ -310,13 +310,13 @@ export class AuthService implements AuthServiceAbstraction { + let request: TokenRequest; + if (twoFactorToken != null && twoFactorProvider != null) { + request = new TokenRequest(emailPassword, codeCodeVerifier, clientIdClientSecret, twoFactorProvider, +- twoFactorToken, remember, captchaToken, deviceRequest); ++ twoFactorToken, remember, captchaToken, deviceRequest, orgId); + } else if (storedTwoFactorToken != null) { + request = new TokenRequest(emailPassword, codeCodeVerifier, clientIdClientSecret, +- TwoFactorProviderType.Remember, storedTwoFactorToken, false, captchaToken, deviceRequest); ++ TwoFactorProviderType.Remember, storedTwoFactorToken, false, captchaToken, deviceRequest, orgId); + } else { + request = new TokenRequest(emailPassword, codeCodeVerifier, clientIdClientSecret, null, +- null, false, captchaToken, deviceRequest); ++ null, false, captchaToken, deviceRequest, orgId); + } + + 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.component.ts b/src/app/app.component.ts +index f01ecb69..22fd7dc2 100644 +--- a/src/app/app.component.ts ++++ b/src/app/app.component.ts +@@ -160,6 +160,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/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 @@ +