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