Browse Source

merge & rebase for pinpox SSO

pull/2787/head
Skiepp 3 years ago
parent
commit
8ca30e6fd7
  1. 1
      Cargo.lock
  2. 4
      Cargo.toml
  3. 2
      migrations/mysql/2021-09-16-133000_add_sso/down.sql
  4. 18
      migrations/mysql/2021-09-16-133000_add_sso/up.sql
  5. 2
      migrations/postgresql/2021-09-16-133000_add_sso/down.sql
  6. 18
      migrations/postgresql/2021-09-16-133000_add_sso/up.sql
  7. 2
      migrations/sqlite/2021-09-16-133000_add_sso/down.sql
  8. 18
      migrations/sqlite/2021-09-16-133000_add_sso/up.sql
  9. 207
      src/api/core/organizations.rs
  10. 246
      src/api/identity.rs
  11. 4
      src/db/models/mod.rs
  12. 2
      src/db/models/org_policy.rs
  13. 27
      src/db/models/organization.rs
  14. 104
      src/db/models/sso_config.rs
  15. 71
      src/db/models/sso_nonce.rs
  16. 23
      src/db/schemas/mysql/schema.rs
  17. 23
      src/db/schemas/postgresql/schema.rs
  18. 23
      src/db/schemas/sqlite/schema.rs
  19. 686
      web-vault-sso.patch

1
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",

4
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"] }

2
migrations/mysql/2021-09-16-133000_add_sso/down.sql

@ -0,0 +1,2 @@
DROP TABLE sso_nonce;
DROP TABLE sso_config;

18
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
);

2
migrations/postgresql/2021-09-16-133000_add_sso/down.sql

@ -0,0 +1,2 @@
DROP TABLE sso_nonce;
DROP TABLE sso_config;

18
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
);

2
migrations/sqlite/2021-09-16-133000_add_sso/down.sql

@ -0,0 +1,2 @@
DROP TABLE sso_nonce;
DROP TABLE sso_config;

18
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
);

207
src/api/core/organizations.rs

@ -31,6 +31,8 @@ pub fn routes() -> Vec<Route> {
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<String>,
}
#[derive(Deserialize, Debug)]
#[allow(non_snake_case)]
struct OrganizationSsoUpdateData {
Enabled: Option<bool>,
Data: Option<SsoOrganizationData>,
}
#[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<String>,
// clientId: Option<String>,
// clientSecret: Option<String>,
AcrValues: Option<String>,
AdditionalEmailClaimTypes: Option<String>,
AdditionalNameClaimTypes: Option<String>,
AdditionalScopes: Option<String>,
AdditionalUserIdClaimTypes: Option<String>,
Authority: Option<String>,
ClientId: Option<String>,
ClientSecret: Option<String>,
ConfigType: Option<String>,
ExpectedReturnAcrValue: Option<String>,
GetClaimsFromUserInfoEndpoint: Option<bool>,
IdpAllowUnsolicitedAuthnResponse: Option<bool>,
IdpArtifactResolutionServiceUrl: Option<String>,
IdpBindingType: Option<u8>,
IdpDisableOutboundLogoutRequests: Option<bool>,
IdpEntityId: Option<String>,
IdpOutboundSigningAlgorithm: Option<String>,
IdpSingleLogoutServiceUrl: Option<String>,
IdpSingleSignOnServiceUrl: Option<String>,
IdpWantAuthnRequestsSigned: Option<bool>,
IdpX509PublicCert: Option<String>,
KeyConnectorUrlY: Option<String>,
KeyConnectorEnabled: Option<bool>,
MetadataAddress: Option<String>,
RedirectBehavior: Option<String>,
SpMinIncomingSigningAlgorithm: Option<String>,
SpNameIdFormat: Option<u8>,
SpOutboundSigningAlgorithm: Option<String>,
SpSigningBehavior: Option<u8>,
SpValidateCertificates: Option<bool>,
SpWantAssertionsSigned: Option<bool>,
}
#[derive(Deserialize)]
#[allow(non_snake_case)]
struct OrgKeyData {
@ -134,6 +227,7 @@ async fn create_organization(headers: Headers, data: JsonUpcase<OrgData>, 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<OrgData>, 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/<org_id>/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/<org_id>/sso", data = "<data>")]
async fn put_organization_sso(
org_id: String,
_headers: OwnerHeaders,
data: JsonUpcase<OrganizationSsoUpdateData>,
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<Value> {

246
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<Route> {
routes![login, prelogin]
routes![login, prelogin, prevalidate, authorize]
}
#[post("/connect/token", data = "<data>")]
@ -51,6 +55,13 @@ async fn login(data: Form<ConnectData>, 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::<TokenPayload>(access_token.as_str()).unwrap().claims;
let mut validation = jsonwebtoken::Validation::default();
validation.insecure_disable_signature_validation();
let token = jsonwebtoken::decode::<TokenPayload>(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<i32>,
// Needed for authorization code
code: Option<String>,
org_identifier: Option<String>,
}
// TODO Might need to migrate this: https://github.com/SergioBenitez/Rocket/pull/1489#issuecomment-1114750006
fn _check_is_some<T>(value: &Option<T>, msg: &str) -> EmptyResult {
if value.is_none() {
err!(msg)
}
Ok(())
}
#[get("/account/prevalidate?<domainHint>")]
#[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<CoreClient, &'static str> {
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?<domain_hint>&<state>")]
async fn authorize(domain_hint: String, state: String, conn: DbConn) -> ApiResult<Redirect> {
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::<CoreResponseType>::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"),
}
}

4
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};

2
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,

27
src/db/models/organization.rs

@ -12,6 +12,7 @@ db_object! {
pub uuid: String,
pub name: String,
pub billing_email: String,
pub identifier: Option<String>,
pub private_key: Option<String>,
pub public_key: Option<String>,
}
@ -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<Self> {
db_run! { conn: {
organizations::table
.filter(organizations::identifier.eq(identifier))
.first::<OrganizationDb>(conn)
.ok().from_db()
}}
}
pub async fn get_all(conn: &DbConn) -> Vec<Self> {
db_run! { conn: {
organizations::table.load::<OrganizationDb>(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,

104
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<String>,
pub client_id: Option<String>,
pub client_secret: Option<String>,
}
}
/// 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<Self> {
db_run! { conn: {
sso_config::table
.filter(sso_config::org_uuid.eq(org_uuid))
.first::<SsoConfigDb>(conn)
.ok()
.from_db()
}}
}
}

71
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<Self> {
db_run! { conn: {
sso_nonce::table
.filter(sso_nonce::org_uuid.eq(org_uuid))
.filter(sso_nonce::nonce.eq(nonce))
.first::<SsoNonceDb>(conn)
.ok()
.from_db()
}}
}
}

23
src/db/schemas/mysql/schema.rs

@ -100,11 +100,25 @@ table! {
uuid -> Text,
name -> Text,
billing_email -> Text,
identifier -> Nullable<Text>,
private_key -> Nullable<Text>,
public_key -> Nullable<Text>,
}
}
table! {
sso_config (uuid) {
uuid -> Text,
org_uuid -> Text,
use_sso -> Bool,
callback_path -> Text,
signed_out_callback_path -> Text,
authority -> Nullable<Text>,
client_id -> Nullable<Text>,
client_secret -> Nullable<Text>,
}
}
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,

23
src/db/schemas/postgresql/schema.rs

@ -100,11 +100,25 @@ table! {
uuid -> Text,
name -> Text,
billing_email -> Text,
identifier -> Nullable<Text>,
private_key -> Nullable<Text>,
public_key -> Nullable<Text>,
}
}
table! {
sso_config (uuid) {
uuid -> Text,
org_uuid -> Text,
use_sso -> Bool,
callback_path -> Text,
signed_out_callback_path -> Text,
authority -> Nullable<Text>,
client_id -> Nullable<Text>,
client_secret -> Nullable<Text>,
}
}
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,

23
src/db/schemas/sqlite/schema.rs

@ -100,11 +100,25 @@ table! {
uuid -> Text,
name -> Text,
billing_email -> Text,
identifier -> Nullable<Text>,
private_key -> Nullable<Text>,
public_key -> Nullable<Text>,
}
}
table! {
sso_config (uuid) {
uuid -> Text,
org_uuid -> Text,
use_sso -> Bool,
callback_path -> Text,
signed_out_callback_path -> Text,
authority -> Nullable<Text>,
client_id -> Nullable<Text>,
client_secret -> Nullable<Text>,
}
}
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,

686
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<any>;
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<string>(ConstantsService.ssoCodeVerifierKey);
const state = await this.storageService.get<string>(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<TaxInfoResponse>;
postOrganization: (request: OrganizationCreateRequest) => Promise<OrganizationResponse>;
putOrganization: (id: string, request: OrganizationUpdateRequest) => Promise<OrganizationResponse>;
+ getSsoConfig: (id: string) => Promise<SsoConfigResponse>;
+ putOrganizationSso: (id: string, request: OrganizationSsoUpdateRequest) => Promise<SsoConfigResponse>;
putOrganizationTaxInfo: (id: string, request: OrganizationTaxInfoUpdateRequest) => Promise<any>;
postLeaveOrganization: (id: string) => Promise<any>;
postOrganizationLicense: (data: FormData) => Promise<OrganizationResponse>;
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<AuthResult>;
- logInSso: (code: string, codeVerifier: string, redirectUrl: string) => Promise<AuthResult>;
+ logInSso: (code: string, codeVerifier: string, redirectUrl: string, orgIdentifier: string) => Promise<AuthResult>;
logInApiKey: (clientId: string, clientSecret: string) => Promise<AuthResult>;
logInTwoFactor: (twoFactorProvider: TwoFactorProviderType, twoFactorToken: string,
remember?: boolean) => Promise<AuthResult>;
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<SsoConfigResponse> {
+ const r = await this.send('GET', '/organizations/' + id + '/sso', null, true, true);
+ return new SsoConfigResponse(r);
+ }
+
+ async putOrganizationSso(id: string, request: OrganizationSsoUpdateRequest): Promise<SsoConfigResponse> {
+ const r = await this.send('PUT', '/organizations/' + id + '/sso', request, true, false);
+ return new SsoConfigResponse(r);
+ }
+
async putOrganizationTaxInfo(id: string, request: OrganizationTaxInfoUpdateRequest): Promise<any> {
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<AuthResult> {
+ async logInSso(code: string, codeVerifier: string, redirectUrl: string, orgIdentifier: string): Promise<AuthResult> {
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<AuthResult> {
@@ -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<AuthResult> {
+ twoFactorProvider?: TwoFactorProviderType, twoFactorToken?: string, remember?: boolean, orgIdentifier?: string): Promise<AuthResult> {
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 @@
</a>
</p>
<p>You can <a href="/">return to the web vault</a>, check our <a href="https://status.bitwarden.com/">status page</a>
- or <a href="https://bitwarden.com/contact/">contact us</a>.</p>
+ or <a href="https://github.com/dani-garcia/vaultwarden">contact us</a>.</p>
</div>
<div class="container footer text-muted content">
- © Copyright 2021 Bitwarden, Inc.
+ © Copyright 2021 Bitwarden, Inc. (Powered by Vaultwarden)
</div>
</body>
</html>
diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts
index bee8416f..dad32467 100644
--- a/src/app/app-routing.module.ts
+++ b/src/app/app-routing.module.ts
@@ -33,6 +33,7 @@ import { AccountComponent as OrgAccountComponent } from './organizations/setting
import { OrganizationBillingComponent } from './organizations/settings/organization-billing.component';
import { OrganizationSubscriptionComponent } from './organizations/settings/organization-subscription.component';
import { SettingsComponent as OrgSettingsComponent } from './organizations/settings/settings.component';
+import { SsoComponent as OrgSsoComponent } from './organizations/settings/sso.component';
import {
TwoFactorSetupComponent as OrgTwoFactorSetupComponent,
} from './organizations/settings/two-factor-setup.component';
@@ -412,6 +413,7 @@ const routes: Routes = [
children: [
{ path: '', pathMatch: 'full', redirectTo: 'account' },
{ path: 'account', component: OrgAccountComponent, data: { titleId: 'myOrganization' } },
+ { path: 'sso', component: OrgSsoComponent, data: { titleId: 'sso' } },
{ path: 'two-factor', component: OrgTwoFactorSetupComponent, data: { titleId: 'twoStepLogin' } },
{
path: 'billing',
diff --git a/src/app/app.component.ts b/src/app/app.component.ts
index 2922cf09..8f2be1ad 100644
--- a/src/app/app.component.ts
+++ b/src/app/app.component.ts
@@ -146,6 +146,10 @@ export class AppComponent implements OnDestroy, OnInit {
}
break;
case 'showToast':
+ if (typeof message.text === "string" && typeof crypto.subtle === 'undefined') {
+ message.title="This browser requires HTTPS to use the web vault";
+ message.text="Check the Vaultwarden wiki for details on how to enable it";
+ }
this.showToast(message);
break;
case 'setFullWidth':
diff --git a/src/app/app.module.ts b/src/app/app.module.ts
index bafc22a0..938db7ee 100644
--- a/src/app/app.module.ts
+++ b/src/app/app.module.ts
@@ -67,6 +67,7 @@ import { DownloadLicenseComponent } from './organizations/settings/download-lice
import { OrganizationBillingComponent } from './organizations/settings/organization-billing.component';
import { OrganizationSubscriptionComponent } from './organizations/settings/organization-subscription.component';
import { SettingsComponent as OrgSettingComponent } from './organizations/settings/settings.component';
+import { SsoComponent as OrgSsoComponent } from './organizations/settings/sso.component';
import {
TwoFactorSetupComponent as OrgTwoFactorSetupComponent,
} from './organizations/settings/two-factor-setup.component';
@@ -347,6 +348,7 @@ registerLocaleData(localeZhTw, 'zh-TW');
NavbarComponent,
OptionsComponent,
OrgAccountComponent,
+ OrgSsoComponent,
OrgAddEditComponent,
OrganizationBillingComponent,
OrganizationPlansComponent,
diff --git a/src/app/layouts/footer.component.html b/src/app/layouts/footer.component.html
index b001b9e3..c1bd2ac8 100644
--- a/src/app/layouts/footer.component.html
+++ b/src/app/layouts/footer.component.html
@@ -1,7 +1,7 @@
<div class="container footer text-muted">
<div class="row">
<div class="col">
- &copy; {{year}}, Bitwarden Inc.
+ &copy; {{year}}, Bitwarden Inc. (Powered by Vaultwarden)
</div>
<div class="col text-center"></div>
<div class="col text-right">
diff --git a/src/app/layouts/frontend-layout.component.html b/src/app/layouts/frontend-layout.component.html
index 4c2c4ca1..dc990b22 100644
--- a/src/app/layouts/frontend-layout.component.html
+++ b/src/app/layouts/frontend-layout.component.html
@@ -1,5 +1,5 @@
<router-outlet></router-outlet>
<div class="container my-5 text-muted text-center">
- &copy; {{year}}, Bitwarden Inc.
+ &copy; {{year}}, Bitwarden Inc. (Powered by Vaultwarden)
<br> {{'versionNumber' | i18n : version}}
</div>
diff --git a/src/app/layouts/navbar.component.html b/src/app/layouts/navbar.component.html
index b28897c9..524764c6 100644
--- a/src/app/layouts/navbar.component.html
+++ b/src/app/layouts/navbar.component.html
@@ -38,7 +38,7 @@
<i class="fa fa-fw fa-user" aria-hidden="true"></i>
{{'myAccount' | i18n}}
</a>
- <a class="dropdown-item" href="https://help.bitwarden.com" target="_blank" rel="noopener">
+ <a class="dropdown-item" href="https://github.com/dani-garcia/vaultwarden" target="_blank" rel="noopener">
<i class="fa fa-fw fa-question-circle" aria-hidden="true"></i>
{{'getHelp' | i18n}}
</a>
diff --git a/src/app/organizations/settings/organization-subscription.component.ts b/src/app/organizations/settings/organization-subscription.component.ts
index 5ac864b3..a405ea37 100644
--- a/src/app/organizations/settings/organization-subscription.component.ts
+++ b/src/app/organizations/settings/organization-subscription.component.ts
@@ -105,7 +105,7 @@ export class OrganizationSubscriptionComponent implements OnInit {
const contactSupport = await this.platformUtilsService.showDialog(this.i18nService.t('changeBillingPlanDesc'),
this.i18nService.t('changeBillingPlan'), this.i18nService.t('contactSupport'), this.i18nService.t('close'));
if (contactSupport) {
- this.platformUtilsService.launchUri('https://bitwarden.com/contact');
+ this.platformUtilsService.launchUri('https://github.com/dani-garcia/vaultwarden');
}
}
diff --git a/src/app/organizations/settings/settings.component.html b/src/app/organizations/settings/settings.component.html
index 2dac5ac1..21ce9848 100644
--- a/src/app/organizations/settings/settings.component.html
+++ b/src/app/organizations/settings/settings.component.html
@@ -7,6 +7,9 @@
<a routerLink="account" class="list-group-item" routerLinkActive="active">
{{'myOrganization' | i18n}}
</a>
+ <a routerLink="sso" class="list-group-item" routerLinkActive="active">
+ {{'singleSignOn' | i18n}}
+ </a>
<a routerLink="subscription" class="list-group-item" routerLinkActive="active">
{{'subscription' | i18n}}
</a>
diff --git a/src/app/organizations/settings/sso.component.html b/src/app/organizations/settings/sso.component.html
index 41d0e89e..c1f2ccf5 100644
--- a/src/app/organizations/settings/sso.component.html
+++ b/src/app/organizations/settings/sso.component.html
@@ -5,38 +5,38 @@
<i class="fa fa-spinner fa-spin text-muted" title="{{'loading' | i18n}}" aria-hidden="true"></i>
<span class="sr-only">{{'loading' | i18n}}</span>
</div>
-<form *ngIf="org && !loading" #form (ngSubmit)="submit()" [appApiAction]="formPromise" ngNativeValidate>
+<form *ngIf="ssoConfig && !loading" #form (ngSubmit)="submit()" [appApiAction]="formPromise" ngNativeValidate>
<div class="row">
<div class="col-12">
<div class="form-group">
<label for="enabled">{{'enabled' | i18n}}</label>
- <input id="enabled" class="form-control" type="checkbox" name="Enabled" [(ngModel)]="org.useSso" [disabled]="selfHosted">
+ <input id="enabled" class="form-control" type="checkbox" name="Enabled" [(ngModel)]="ssoConfig.useSso" [disabled]="selfHosted">
</div>
<h2>OpenId Connect Configuration</h2>
<div class="form-group">
<label for="callbackPath">{{'callbackPath' | i18n}}</label>
- <input id="callbackPath" class="form-control" type="text" name="Callback Path" [(ngModel)]="org.callbackPath"
+ <input id="callbackPath" class="form-control" type="text" name="Callback Path" [(ngModel)]="ssoConfig.callbackPath"
[disabled]="selfHosted">
</div>
<div class="form-group">
<label for="signedOutCallbackPath">{{'signedOutCallbackPath' | i18n}}</label>
<input id="signedOutCallbackPath" class="form-control" type="text" name="Signed Out Callback Path"
- [(ngModel)]="org.signedOutCallbackPath" [disabled]="selfHosted">
+ [(ngModel)]="ssoConfig.signedOutCallbackPath" [disabled]="selfHosted">
</div>
<div class="form-group">
<label for="authority">{{'authority' | i18n}}</label>
<input id="authority" class="form-control" type="text" name="Authority"
- [(ngModel)]="org.authority" [disabled]="selfHosted">
+ [(ngModel)]="ssoConfig.authority" [disabled]="selfHosted">
</div>
<div class="form-group">
<label for="clientId">{{'clientId' | i18n}}</label>
<input id="authority" class="form-control" type="text" name="Client ID"
- [(ngModel)]="org.clientId" [disabled]="selfHosted">
+ [(ngModel)]="ssoConfig.clientId" [disabled]="selfHosted">
</div>
<div class="form-group">
<label for="clientSecret">{{'clientSecret' | i18n}}</label>
<input id="clientSecret" class="form-control" type="password" name="Client Secret"
- [(ngModel)]="org.clientSecret" [disabled]="selfHosted">
+ [(ngModel)]="ssoConfig.clientSecret" [disabled]="selfHosted">
</div>
</div>
</div>
@@ -45,7 +45,7 @@
<span>{{'save' | i18n}}</span>
</button>
</form>
-<div *ngIf="!org || loading">
+<div *ngIf="!ssoConfig || loading">
<i class="fa fa-spinner fa-spin text-muted" title="{{'loading' | i18n}}" aria-hidden="true"></i>
<span class="sr-only">{{'loading' | i18n}}</span>
</div>
diff --git a/src/app/organizations/settings/sso.component.ts b/src/app/organizations/settings/sso.component.ts
index f40a54f2..5eeef132 100644
--- a/src/app/organizations/settings/sso.component.ts
+++ b/src/app/organizations/settings/sso.component.ts
@@ -17,7 +17,7 @@ import { SyncService } from 'jslib-common/abstractions/sync.service';
import { OrganizationSsoUpdateRequest } from 'jslib-common/models/request/organizationSsoUpdateRequest';
-import { OrganizationResponse } from 'jslib-common/models/response/organizationResponse';
+import { SsoConfigResponse } from 'jslib-common/models/response/ssoConfigResponse';
import { ModalComponent } from '../../modal.component';
@@ -28,7 +28,7 @@ import { ModalComponent } from '../../modal.component';
export class SsoComponent {
selfHosted = false;
loading = true;
- org: OrganizationResponse;
+ ssoConfig: SsoConfigResponse;
formPromise: Promise<any>;
private organizationId: string;
@@ -45,7 +45,7 @@ export class SsoComponent {
this.route.parent.parent.params.subscribe(async params => {
this.organizationId = params.organizationId;
try {
- this.org = await this.apiService.getOrganization(this.organizationId);
+ this.ssoConfig = await this.apiService.getSsoConfig(this.organizationId);
} catch { }
});
this.loading = false;
@@ -54,12 +54,12 @@ export class SsoComponent {
async submit() {
try {
const request = new OrganizationSsoUpdateRequest();
- request.useSso = this.org.useSso;
- request.callbackPath = this.org.callbackPath;
- request.signedOutCallbackPath = this.org.signedOutCallbackPath;
- request.authority = this.org.authority;
- request.clientId = this.org.clientId;
- request.clientSecret = this.org.clientSecret;
+ request.useSso = this.ssoConfig.useSso;
+ request.callbackPath = this.ssoConfig.callbackPath;
+ request.signedOutCallbackPath = this.ssoConfig.signedOutCallbackPath;
+ request.authority = this.ssoConfig.authority;
+ request.clientId = this.ssoConfig.clientId;
+ request.clientSecret = this.ssoConfig.clientSecret;
this.formPromise = this.apiService.putOrganizationSso(this.organizationId, request).then(() => {
return this.syncService.fullSync(true);
diff --git a/src/app/send/access.component.html b/src/app/send/access.component.html
index 84944a2b..b736bbe4 100644
--- a/src/app/send/access.component.html
+++ b/src/app/send/access.component.html
@@ -82,10 +82,7 @@
<div class="col-12 text-center mt-5 text-muted">
<p class="mb-0">{{'sendAccessTaglineProductDesc' | i18n}}<br>
{{'sendAccessTaglineLearnMore' | i18n}} <a
- href="https://www.bitwarden.com/products/send?source=web-vault" target="_blank">Bitwarden Send</a>
- {{'sendAccessTaglineOr' | i18n}} <a
- href="https://vault.bitwarden.com/#/register" target="_blank">{{'sendAccessTaglineSignUp' | i18n}}</a>
- {{'sendAccessTaglineTryToday' | i18n}}
+ href="https://www.bitwarden.com/products/send/" target="_blank">Bitwarden Send</a>.
</p>
</div>
</div>
diff --git a/src/app/services/services.module.ts b/src/app/services/services.module.ts
index 231edc51..2fb39433 100644
--- a/src/app/services/services.module.ts
+++ b/src/app/services/services.module.ts
@@ -142,18 +142,25 @@ const passwordRepromptService = new PasswordRepromptService(i18nService, cryptoS
containerService.attachToWindow(window);
export function initFactory(): Function {
+ function getBaseUrl() {
+ // If the base URL is `https://bitwarden.example.com/base/path/`,
+ // `window.location.href` should have one of the following forms:
+ //
+ // - `https://bitwarden.example.com/base/path/`
+ // - `https://bitwarden.example.com/base/path/#/some/route[?queryParam=...]`
+ //
+ // We want to get to just `https://bitwarden.example.com/base/path`.
+ let baseUrl = window.location.origin;
+ baseUrl = baseUrl.replace(/#.*/, ''); // Strip off `#` and everything after.
+ baseUrl = baseUrl.replace(/\/+$/, ''); // Trim any trailing `/` chars.
+ return baseUrl;
+ }
return async () => {
await (storageService as HtmlStorageService).init();
- if (process.env.ENV !== 'production' || platformUtilsService.isSelfHost()) {
- environmentService.baseUrl = window.location.origin;
- } else {
- environmentService.notificationsUrl = 'https://notifications.bitwarden.com';
- environmentService.enterpriseUrl = 'https://portal.bitwarden.com';
- }
-
+ environmentService.baseUrl = getBaseUrl();
apiService.setUrls({
- base: window.location.origin,
+ base: environmentService.baseUrl,
api: null,
identity: null,
events: null,
diff --git a/src/app/vault/vault.component.ts b/src/app/vault/vault.component.ts
index 41216ead..70dec887 100644
--- a/src/app/vault/vault.component.ts
+++ b/src/app/vault/vault.component.ts
@@ -80,9 +80,7 @@ export class VaultComponent implements OnInit, OnDestroy {
async ngOnInit() {
this.showVerifyEmail = !(await this.tokenService.getEmailVerified());
this.showBrowserOutdated = window.navigator.userAgent.indexOf('MSIE') !== -1;
- this.trashCleanupWarning = this.i18nService.t(
- this.platformUtilsService.isSelfHost() ? 'trashCleanupWarningSelfHosted' : 'trashCleanupWarning'
- );
+ this.trashCleanupWarning = this.i18nService.t('trashCleanupWarningSelfHosted');
const queryParamsSub = this.route.queryParams.subscribe(async params => {
await this.syncService.fullSync(false);
diff --git a/src/locales/en/messages.json b/src/locales/en/messages.json
index e680001c..f16bd676 100644
--- a/src/locales/en/messages.json
+++ b/src/locales/en/messages.json
@@ -3277,6 +3277,9 @@
"enterpriseSingleSignOn": {
"message": "Enterprise Single Sign-On"
},
+ "singleSignOn": {
+ "message": "Single Sign-On"
+ },
"ssoHandOff": {
"message": "You may now close this tab and continue in the extension."
},
@@ -3998,5 +4001,20 @@
},
"resetPasswordManageUsers": {
"message": "Manage Users must also be enabled with the Manage Password Reset permission"
+ },
+ "callbackPath": {
+ "message": "Callback Path"
+ },
+ "signedOutCallbackPath": {
+ "message": "Signed Out Callback Path"
+ },
+ "authority": {
+ "message": "Authority"
+ },
+ "clientId": {
+ "message": "Client Id"
+ },
+ "clientSecret": {
+ "message": "Client Secret"
}
}
diff --git a/src/scss/styles.scss b/src/scss/styles.scss
index 598fea83..0b702064 100644
--- a/src/scss/styles.scss
+++ b/src/scss/styles.scss
@@ -1,5 +1,53 @@
@import "../css/webfonts.css";
+/**** START Bitwarden_RS CHANGES ****/
+/* This combines all selectors extending it into one */
+%bwrs-hide { display: none !important; }
+
+/* This allows searching for the combined style in the browsers dev-tools (look into the head tag) */
+#bwrs-hide, head { @extend %bwrs-hide; }
+
+/* Hide any link pointing to billing */
+a[href$="/settings/billing"] { @extend %bwrs-hide; }
+
+/* Hide any link pointing to subscriptions */
+a[href$="/settings/subscription"] { @extend %bwrs-hide; }
+
+/* Hide any link pointing to emergency access */
+a[href$="/settings/emergency-access"] { @extend %bwrs-hide; }
+
+/* Hide the info box that advertises Bitwarden Send */
+app-send-info.d-block { @extend %bwrs-hide; }
+
+/* Hide Two-Factor menu in Organization settings */
+app-org-settings a[href$="/settings/two-factor"] { @extend %bwrs-hide; }
+
+/* Hide organization plans */
+app-organization-plans > form > div.form-check { @extend %bwrs-hide; }
+app-organization-plans > form > h2.mt-5 { @extend %bwrs-hide; }
+
+/* Hide the `API Key` section under `My Account` */
+app-account > div:nth-child(9),
+app-account > p,
+app-account > button:nth-child(11),
+app-account > button:nth-child(12) {
+ @extend %bwrs-hide;
+}
+
+/* Hide the radio button and label for the `Custom` org user type */
+#userTypeCustom, label[for^=userTypeCustom] {
+ @extend %bwrs-hide;
+}
+
+/* Hide the warning that policy config is moving to Business Portal */
+app-org-policies > app-callout { @extend %bwrs-hide; }
+
+/* Hide Tax Info and Form in Organization settings */
+app-org-account > div.secondary-header:nth-child(3) { @extend %bwrs-hide; }
+app-org-account > div.secondary-header:nth-child(3) + p { @extend %bwrs-hide; }
+app-org-account > div.secondary-header:nth-child(3) + p + form { @extend %bwrs-hide; }
+/**** END Bitwarden_RS CHANGES ****/
+
$primary: #175DDC;
$primary-accent: #1252A3;
$secondary: #ced4da;
diff --git a/src/services/webPlatformUtils.service.ts b/src/services/webPlatformUtils.service.ts
index e3aeea39..6e7ed1e0 100644
--- a/src/services/webPlatformUtils.service.ts
+++ b/src/services/webPlatformUtils.service.ts
@@ -249,11 +249,12 @@ export class WebPlatformUtilsService implements PlatformUtilsService {
}
isDev(): boolean {
- return process.env.ENV === 'development';
+ return false;
}
+ // Even though Vaultwarden is self-hosted, returning true ends up enabling various license checks.
isSelfHost(): boolean {
- return process.env.SELF_HOST.toString() === 'true';
+ return false;
}
copyToClipboard(text: string, options?: any): void | boolean {
Loading…
Cancel
Save