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