mirror of
https://github.com/dani-garcia/vaultwarden.git
synced 2025-11-07 04:42:59 -05:00
SSO using OpenID Connect (#3899)
* Add SSO functionality using OpenID Connect Co-authored-by: Pablo Ovelleiro Corral <mail@pablo.tools> Co-authored-by: Stuart Heap <sheap13@gmail.com> Co-authored-by: Alex Moore <skiepp@my-dockerfarm.cloud> Co-authored-by: Brian Munro <brian.alexander.munro@gmail.com> Co-authored-by: Jacques B. <timshel@github.com> * Improvements and error handling * Stop rolling device token * Add playwright tests * Activate PKCE by default * Ensure result order when searching for sso_user * add SSO_ALLOW_UNKNOWN_EMAIL_VERIFICATION * Toggle SSO button in scss * Base64 encode state before sending it to providers * Prevent disabled User from SSO login * Review fixes * Remove unused UserOrganization.invited_by_email * Split SsoUser::find_by_identifier_or_email * api::Accounts::verify_password add the policy even if it's ignored * Disable signups if SSO_ONLY is activated * Add verifiedDate to organizations::get_org_domain_sso_details * Review fixes * Remove OrganizationId guard from get_master_password_policy * Add wrapper type OIDCCode OIDCState OIDCIdentifier * Membership::confirm_user_invitations fix and tests * Allow set-password only if account is unitialized * Review fixes * Prevent accepting another user invitation * Log password change event on SSO account creation * Unify master password policy resolution * Upgrade openidconnect to 4.0.0 * Revert "Remove unused UserOrganization.invited_by_email" This reverts commit 548e19995e141314af98a10d170ea7371f02fab4. * Process org enrollment in accounts::post_set_password * Improve tests * Pass the claim invited_by_email in case it was not in db * Add Slack configuration hints * Fix playwright tests * Skip broken tests * Add sso identifier in admin user panel * Remove duplicate expiration check, add a log * Augment mobile refresh_token validity * Rauthy configuration hints * Fix playwright tests * Playwright upgrade and conf improvement * Playwright tests improvements * 2FA email and device creation change * Fix and improve Playwright tests * Minor improvements * Fix enforceOnLogin org policies * Run playwright sso tests against correct db * PKCE should now work with Zitadel * Playwright upgrade maildev to use MailBuffer.expect * Upgrades playwright tests deps * Check email_verified in id_token and user_info * Add sso verified endpoint for v2025.6.0 * Fix playwright tests * Create a separate sso_client * Upgrade openidconnect to 4.0.1 * Server settings for login fields toggle * Use only css for login fields * Fix playwright test * Review fix * More review fix * Perform same checks when setting kdf --------- Co-authored-by: Felix Eckhofer <felix@eckhofer.com> Co-authored-by: Pablo Ovelleiro Corral <mail@pablo.tools> Co-authored-by: Stuart Heap <sheap13@gmail.com> Co-authored-by: Alex Moore <skiepp@my-dockerfarm.cloud> Co-authored-by: Brian Munro <brian.alexander.munro@gmail.com> Co-authored-by: Jacques B. <timshel@github.com> Co-authored-by: Timshel <timshel@480s>
This commit is contained in:
@@ -7,13 +7,13 @@ use std::collections::{HashMap, HashSet};
|
||||
use crate::api::admin::FAKE_ADMIN_UUID;
|
||||
use crate::{
|
||||
api::{
|
||||
core::{log_event, two_factor, CipherSyncData, CipherSyncType},
|
||||
core::{accept_org_invite, log_event, two_factor, CipherSyncData, CipherSyncType},
|
||||
EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType,
|
||||
},
|
||||
auth::{decode_invite, AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders},
|
||||
db::{models::*, DbConn},
|
||||
mail,
|
||||
util::{convert_json_key_lcase_first, NumberOrString},
|
||||
util::{convert_json_key_lcase_first, get_uuid, NumberOrString},
|
||||
CONFIG,
|
||||
};
|
||||
|
||||
@@ -43,6 +43,7 @@ pub fn routes() -> Vec<Route> {
|
||||
bulk_delete_organization_collections,
|
||||
post_bulk_collections,
|
||||
get_org_details,
|
||||
get_org_domain_sso_verified,
|
||||
get_members,
|
||||
send_invite,
|
||||
reinvite_member,
|
||||
@@ -60,6 +61,7 @@ pub fn routes() -> Vec<Route> {
|
||||
post_org_import,
|
||||
list_policies,
|
||||
list_policies_token,
|
||||
get_master_password_policy,
|
||||
get_policy,
|
||||
put_policy,
|
||||
get_organization_tax,
|
||||
@@ -103,6 +105,7 @@ pub fn routes() -> Vec<Route> {
|
||||
api_key,
|
||||
rotate_api_key,
|
||||
get_billing_metadata,
|
||||
get_auto_enroll_status,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -192,7 +195,7 @@ async fn create_organization(headers: Headers, data: Json<OrgData>, mut conn: Db
|
||||
};
|
||||
|
||||
let org = Organization::new(data.name, data.billing_email, private_key, public_key);
|
||||
let mut member = Membership::new(headers.user.uuid, org.uuid.clone());
|
||||
let mut member = Membership::new(headers.user.uuid, org.uuid.clone(), None);
|
||||
let collection = Collection::new(org.uuid.clone(), data.collection_name, None);
|
||||
|
||||
member.akey = data.key;
|
||||
@@ -335,6 +338,34 @@ async fn get_user_collections(headers: Headers, mut conn: DbConn) -> Json<Value>
|
||||
}))
|
||||
}
|
||||
|
||||
// Called during the SSO enrollment
|
||||
// The `identifier` should be the value returned by `get_org_domain_sso_details`
|
||||
// The returned `Id` will then be passed to `get_master_password_policy` which will mainly ignore it
|
||||
#[get("/organizations/<identifier>/auto-enroll-status")]
|
||||
async fn get_auto_enroll_status(identifier: &str, headers: Headers, mut conn: DbConn) -> JsonResult {
|
||||
let org = if identifier == crate::sso::FAKE_IDENTIFIER {
|
||||
match Membership::find_main_user_org(&headers.user.uuid, &mut conn).await {
|
||||
Some(member) => Organization::find_by_uuid(&member.org_uuid, &mut conn).await,
|
||||
None => None,
|
||||
}
|
||||
} else {
|
||||
Organization::find_by_name(identifier, &mut conn).await
|
||||
};
|
||||
|
||||
let (id, identifier, rp_auto_enroll) = match org {
|
||||
None => (get_uuid(), identifier.to_string(), false),
|
||||
Some(org) => {
|
||||
(org.uuid.to_string(), org.name, OrgPolicy::org_is_reset_password_auto_enroll(&org.uuid, &mut conn).await)
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Json(json!({
|
||||
"Id": id,
|
||||
"Identifier": identifier,
|
||||
"ResetPasswordEnabled": rp_auto_enroll,
|
||||
})))
|
||||
}
|
||||
|
||||
#[get("/organizations/<org_id>/collections")]
|
||||
async fn get_org_collections(org_id: OrganizationId, headers: ManagerHeadersLoose, mut conn: DbConn) -> JsonResult {
|
||||
if org_id != headers.membership.org_uuid {
|
||||
@@ -930,6 +961,39 @@ async fn _get_org_details(
|
||||
Ok(json!(ciphers_json))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct OrgDomainDetails {
|
||||
email: String,
|
||||
}
|
||||
|
||||
// Returning a Domain/Organization here allow to prefill it and prevent prompting the user
|
||||
// So we either return an Org name associated to the user or a dummy value.
|
||||
// In use since `v2025.6.0`, appears to use only the first `organizationIdentifier`
|
||||
#[post("/organizations/domain/sso/verified", data = "<data>")]
|
||||
async fn get_org_domain_sso_verified(data: Json<OrgDomainDetails>, mut conn: DbConn) -> JsonResult {
|
||||
let data: OrgDomainDetails = data.into_inner();
|
||||
|
||||
let identifiers = match Organization::find_org_user_email(&data.email, &mut conn)
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|o| o.name)
|
||||
.collect::<Vec<String>>()
|
||||
{
|
||||
v if !v.is_empty() => v,
|
||||
_ => vec![crate::sso::FAKE_IDENTIFIER.to_string()],
|
||||
};
|
||||
|
||||
Ok(Json(json!({
|
||||
"object": "list",
|
||||
"data": identifiers.into_iter().map(|identifier| json!({
|
||||
"organizationName": identifier, // appear unused
|
||||
"organizationIdentifier": identifier,
|
||||
"domainName": CONFIG.domain(), // appear unused
|
||||
})).collect::<Vec<Value>>()
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(FromForm)]
|
||||
struct GetOrgUserData {
|
||||
#[field(name = "includeCollections")]
|
||||
@@ -1063,7 +1127,7 @@ async fn send_invite(
|
||||
Invitation::new(email).save(&mut conn).await?;
|
||||
}
|
||||
|
||||
let mut new_user = User::new(email.clone());
|
||||
let mut new_user = User::new(email.clone(), None);
|
||||
new_user.save(&mut conn).await?;
|
||||
user_created = true;
|
||||
new_user
|
||||
@@ -1081,7 +1145,7 @@ async fn send_invite(
|
||||
}
|
||||
};
|
||||
|
||||
let mut new_member = Membership::new(user.uuid.clone(), org_id.clone());
|
||||
let mut new_member = Membership::new(user.uuid.clone(), org_id.clone(), Some(headers.user.email.clone()));
|
||||
new_member.access_all = access_all;
|
||||
new_member.atype = new_type;
|
||||
new_member.status = member_status;
|
||||
@@ -1267,71 +1331,39 @@ async fn accept_invite(
|
||||
err!("Invitation was issued to a different account", "Claim does not match user_id")
|
||||
}
|
||||
|
||||
// If a claim org_id does not match the one in from the URI, something is wrong.
|
||||
if !claims.org_id.eq(&org_id) {
|
||||
err!("Error accepting the invitation", "Claim does not match the org_id")
|
||||
}
|
||||
|
||||
// If a claim does not have a member_id or it does not match the one in from the URI, something is wrong.
|
||||
if !claims.member_id.eq(&member_id) {
|
||||
err!("Error accepting the invitation", "Claim does not match the member_id")
|
||||
}
|
||||
|
||||
let member = &claims.member_id;
|
||||
let org = &claims.org_id;
|
||||
|
||||
let member_id = &claims.member_id;
|
||||
Invitation::take(&claims.email, &mut conn).await;
|
||||
|
||||
// skip invitation logic when we were invited via the /admin panel
|
||||
if **member != FAKE_ADMIN_UUID {
|
||||
let Some(mut member) = Membership::find_by_uuid_and_org(member, org, &mut conn).await else {
|
||||
if **member_id != FAKE_ADMIN_UUID {
|
||||
let Some(mut member) = Membership::find_by_uuid_and_org(member_id, &claims.org_id, &mut conn).await else {
|
||||
err!("Error accepting the invitation")
|
||||
};
|
||||
|
||||
if member.status != MembershipStatus::Invited as i32 {
|
||||
err!("User already accepted the invitation")
|
||||
}
|
||||
let reset_password_key = match OrgPolicy::org_is_reset_password_auto_enroll(&member.org_uuid, &mut conn).await {
|
||||
true if data.reset_password_key.is_none() => err!("Reset password key is required, but not provided."),
|
||||
true => data.reset_password_key,
|
||||
false => None,
|
||||
};
|
||||
|
||||
let master_password_required = OrgPolicy::org_is_reset_password_auto_enroll(org, &mut conn).await;
|
||||
if data.reset_password_key.is_none() && master_password_required {
|
||||
err!("Reset password key is required, but not provided.");
|
||||
}
|
||||
// In case the user was invited before the mail was saved in db.
|
||||
member.invited_by_email = member.invited_by_email.or(claims.invited_by_email);
|
||||
|
||||
// This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type
|
||||
// It returns different error messages per function.
|
||||
if member.atype < MembershipType::Admin {
|
||||
match OrgPolicy::is_user_allowed(&member.user_uuid, &org_id, false, &mut conn).await {
|
||||
Ok(_) => {}
|
||||
Err(OrgPolicyErr::TwoFactorMissing) => {
|
||||
if CONFIG.email_2fa_auto_fallback() {
|
||||
two_factor::email::activate_email_2fa(&headers.user, &mut conn).await?;
|
||||
} else {
|
||||
err!("You cannot join this organization until you enable two-step login on your user account");
|
||||
}
|
||||
}
|
||||
Err(OrgPolicyErr::SingleOrgEnforced) => {
|
||||
err!("You cannot join this organization because you are a member of an organization which forbids it");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
member.status = MembershipStatus::Accepted as i32;
|
||||
|
||||
if master_password_required {
|
||||
member.reset_password_key = data.reset_password_key;
|
||||
}
|
||||
|
||||
member.save(&mut conn).await?;
|
||||
}
|
||||
|
||||
if CONFIG.mail_enabled() {
|
||||
if let Some(invited_by_email) = &claims.invited_by_email {
|
||||
let org_name = match Organization::find_by_uuid(&claims.org_id, &mut conn).await {
|
||||
Some(org) => org.name,
|
||||
None => err!("Organization not found."),
|
||||
};
|
||||
// User was invited to an organization, so they must be confirmed manually after acceptance
|
||||
mail::send_invite_accepted(&claims.email, invited_by_email, &org_name).await?;
|
||||
} else {
|
||||
// User was invited from /admin, so they are automatically confirmed
|
||||
let org_name = CONFIG.invitation_org_name();
|
||||
mail::send_invite_confirmed(&claims.email, &org_name).await?;
|
||||
}
|
||||
accept_org_invite(&headers.user, member, reset_password_key, &mut conn).await?;
|
||||
} else if CONFIG.mail_enabled() {
|
||||
// User was invited from /admin, so they are automatically confirmed
|
||||
let org_name = CONFIG.invitation_org_name();
|
||||
mail::send_invite_confirmed(&claims.email, &org_name).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -2025,18 +2057,36 @@ async fn list_policies_token(org_id: OrganizationId, token: &str, mut conn: DbCo
|
||||
})))
|
||||
}
|
||||
|
||||
#[get("/organizations/<org_id>/policies/<pol_type>")]
|
||||
// Called during the SSO enrollment.
|
||||
// Return the org policy if it exists, otherwise use the default one.
|
||||
#[get("/organizations/<org_id>/policies/master-password", rank = 1)]
|
||||
async fn get_master_password_policy(org_id: OrganizationId, _headers: Headers, mut conn: DbConn) -> JsonResult {
|
||||
let policy =
|
||||
OrgPolicy::find_by_org_and_type(&org_id, OrgPolicyType::MasterPassword, &mut conn).await.unwrap_or_else(|| {
|
||||
let data = match CONFIG.sso_master_password_policy() {
|
||||
Some(policy) => policy,
|
||||
None => "null".to_string(),
|
||||
};
|
||||
|
||||
OrgPolicy::new(org_id, OrgPolicyType::MasterPassword, CONFIG.sso_master_password_policy().is_some(), data)
|
||||
});
|
||||
|
||||
Ok(Json(policy.to_json()))
|
||||
}
|
||||
|
||||
#[get("/organizations/<org_id>/policies/<pol_type>", rank = 2)]
|
||||
async fn get_policy(org_id: OrganizationId, pol_type: i32, headers: AdminHeaders, mut conn: DbConn) -> JsonResult {
|
||||
if org_id != headers.org_id {
|
||||
err!("Organization not found", "Organization id's do not match");
|
||||
}
|
||||
|
||||
let Some(pol_type_enum) = OrgPolicyType::from_i32(pol_type) else {
|
||||
err!("Invalid or unsupported policy type")
|
||||
};
|
||||
|
||||
let policy = match OrgPolicy::find_by_org_and_type(&org_id, pol_type_enum, &mut conn).await {
|
||||
Some(p) => p,
|
||||
None => OrgPolicy::new(org_id.clone(), pol_type_enum, "null".to_string()),
|
||||
None => OrgPolicy::new(org_id.clone(), pol_type_enum, false, "null".to_string()),
|
||||
};
|
||||
|
||||
Ok(Json(policy.to_json()))
|
||||
@@ -2147,7 +2197,7 @@ async fn put_policy(
|
||||
|
||||
let mut policy = match OrgPolicy::find_by_org_and_type(&org_id, pol_type_enum, &mut conn).await {
|
||||
Some(p) => p,
|
||||
None => OrgPolicy::new(org_id.clone(), pol_type_enum, "{}".to_string()),
|
||||
None => OrgPolicy::new(org_id.clone(), pol_type_enum, false, "{}".to_string()),
|
||||
};
|
||||
|
||||
policy.enabled = data.enabled;
|
||||
@@ -2306,7 +2356,8 @@ async fn import(org_id: OrganizationId, data: Json<OrgImportData>, headers: Head
|
||||
MembershipStatus::Accepted as i32 // Automatically mark user as accepted if no email invites
|
||||
};
|
||||
|
||||
let mut new_member = Membership::new(user.uuid.clone(), org_id.clone());
|
||||
let mut new_member =
|
||||
Membership::new(user.uuid.clone(), org_id.clone(), Some(headers.user.email.clone()));
|
||||
new_member.access_all = false;
|
||||
new_member.atype = MembershipType::User as i32;
|
||||
new_member.status = member_status;
|
||||
|
||||
Reference in New Issue
Block a user