Browse Source

Address SSO default organization review feedback

pull/7422/head
tom27052006 3 weeks ago
parent
commit
7cbea9af41
  1. 44
      src/api/core/accounts.rs
  2. 18
      src/api/core/organizations.rs
  3. 2
      src/api/identity.rs
  4. 26
      src/sso.rs

44
src/api/core/accounts.rs

@ -22,7 +22,8 @@ use crate::{
models::{
AuthRequest, AuthRequestId, Cipher, CipherId, Device, DeviceId, DeviceType, DeviceWithAuthRequest,
EmergencyAccess, EmergencyAccessId, EventType, Folder, FolderId, Invitation, Membership, MembershipId,
OrgPolicy, OrgPolicyType, Organization, OrganizationId, Send, SendId, User, UserId, UserKdfType,
MembershipStatus, OrgPolicy, OrgPolicyType, Organization, OrganizationId, Send, SendId, User, UserId,
UserKdfType,
},
},
mail,
@ -439,6 +440,15 @@ pub async fn register(data: Json<RegisterData>, email_verification: bool, conn:
async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn: DbConn) -> JsonResult {
let data: SetPasswordData = data.into_inner();
let mut user = headers.user;
let default_org_id = match CONFIG.sso_default_organization_uuid() {
Some(org_uuid) => Some(crate::sso::normalize_organization_uuid(&org_uuid)?),
None => None,
};
let enroll_in_default_organization = matches!(
(data.org_identifier.as_deref(), default_org_id.as_ref()),
(Some(identifier), Some(org_id))
if identifier == crate::sso::FAKE_SSO_IDENTIFIER || identifier == org_id.as_ref()
);
if user.private_key.is_some() {
err!("Account already initialized, cannot set password")
@ -467,6 +477,7 @@ async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn:
}
if let Some(identifier) = data.org_identifier
&& !enroll_in_default_organization
&& identifier != crate::sso::FAKE_SSO_IDENTIFIER
&& identifier != crate::api::admin::FAKE_ADMIN_UUID
{
@ -492,12 +503,43 @@ async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn:
user.save(&conn).await?;
if enroll_in_default_organization && let Some(org_id) = default_org_id {
accept_sso_default_organization_invite(&user, &org_id, &conn).await?;
}
Ok(Json(json!({
"object": "set-password",
"captchaBypassToken": "",
})))
}
async fn accept_sso_default_organization_invite(user: &User, org_id: &OrganizationId, conn: &DbConn) -> EmptyResult {
let Some(mut membership) = Membership::find_by_user_and_org(&user.uuid, org_id, conn).await else {
err!("Failed to retrieve the default organization invitation")
};
if membership.status != MembershipStatus::Invited as i32 {
return Ok(());
}
let Some(org) = Organization::find_by_uuid(org_id, conn).await else {
err!("The organization configured in `SSO_DEFAULT_ORGANIZATION_UUID` does not exist")
};
membership.status = MembershipStatus::Accepted as i32;
OrgPolicy::check_user_allowed(&membership, "join", conn).await?;
membership.save(conn).await?;
if CONFIG.mail_enabled() {
let address = membership.invited_by_email.unwrap_or(org.billing_email);
if let Err(e) = mail::send_invite_accepted(&user.email, &address, &org.name).await {
error!("Error sending default organization enrollment notification: {e:#?}");
}
}
info!("Added SSO user {} to default organization {} pending confirmation", user.uuid, org_id);
Ok(())
}
#[get("/accounts/profile")]
async fn profile(headers: Headers, conn: DbConn) -> Json<Value> {
Json(headers.user.to_json(&conn).await)

18
src/api/core/organizations.rs

@ -910,18 +910,22 @@ async fn get_org_details_impl(
Ok(json!(ciphers_json))
}
// Returning a Domain/Organization here allow to prefill it and prevent prompting the user
// So we return a dummy value, since we only support a single SSO integration, and do not use the response anywhere
// In use since `v2025.6.0`, appears to use only the first `organizationIdentifier`
// Returning a Domain/Organization here allows the client to prefill it and prevents prompting the user.
// Use the configured default organization so its policies apply during SSO enrollment; otherwise return a dummy value.
// In use since `v2025.6.0`, the client appears to use only the first `organizationIdentifier`.
#[post("/organizations/domain/sso/verified")]
fn get_org_domain_sso_verified() -> JsonResult {
// Always return a dummy value, no matter if SSO is enabled or not
let organization_identifier = match CONFIG.sso_default_organization_uuid() {
Some(org_uuid) => crate::sso::normalize_organization_uuid(&org_uuid)?.to_string(),
None => FAKE_SSO_IDENTIFIER.to_owned(),
};
Ok(Json(json!({
"object": "list",
"data": [{
"organizationIdentifier": FAKE_SSO_IDENTIFIER,
// These appear to be unused
"organizationName": FAKE_SSO_IDENTIFIER,
"organizationIdentifier": organization_identifier,
// This appears to be unused.
"organizationName": organization_identifier,
"domainName": CONFIG.domain()
}],
"continuationToken": null

2
src/api/identity.rs

@ -353,7 +353,7 @@ async fn sso_login(
*user_id = Some(user.uuid.clone());
// We passed 2FA get auth tokens
let auth_tokens = sso::redeem(&device, &user, data.client_id, sso_user, sso_auth, user_infos, conn).await?;
let auth_tokens = sso::redeem(&device, &user, data.client_id, sso_user, sso_auth, user_infos, &ip.ip, conn).await?;
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await
}

26
src/sso.rs

@ -1,4 +1,4 @@
use std::{sync::LazyLock, time::Duration};
use std::{net::IpAddr, sync::LazyLock, time::Duration};
use chrono::Utc;
use derive_more::{AsRef, Deref, Display, From, Into};
@ -7,14 +7,14 @@ use url::Url;
use crate::{
CONFIG,
api::ApiResult,
api::{ApiResult, core::log_event},
auth,
auth::{AuthMethod, AuthTokens, BW_EXPIRATION, DEFAULT_REFRESH_VALIDITY, TokenWrapper},
db::{
DbConn,
models::{
Device, Membership, MembershipStatus, MembershipType, OIDCAuthenticatedUser, Organization, OrganizationId,
SsoAuth, SsoUser, User,
Device, EventType, Membership, MembershipStatus, MembershipType, OIDCAuthenticatedUser, Organization,
OrganizationId, SsoAuth, SsoUser, User,
},
},
sso_client::Client,
@ -319,6 +319,7 @@ pub async fn exchange_code(
}
// User has passed 2FA flow we can delete auth info from database
#[expect(clippy::too_many_arguments)]
pub async fn redeem(
device: &Device,
user: &User,
@ -326,12 +327,13 @@ pub async fn redeem(
sso_user: Option<SsoUser>,
sso_auth: SsoAuth,
auth_user: OIDCAuthenticatedUser,
ip: &IpAddr,
conn: &DbConn,
) -> ApiResult<AuthTokens> {
sso_auth.delete(conn).await?;
if sso_user.is_none() {
enroll_user_in_default_organization(user, conn).await?;
invite_user_to_default_organization(user, device.atype, ip, conn).await?;
let user_sso = SsoUser {
user_uuid: user.uuid.clone(),
@ -359,7 +361,12 @@ pub async fn redeem(
}
}
async fn enroll_user_in_default_organization(user: &User, conn: &DbConn) -> ApiResult<()> {
async fn invite_user_to_default_organization(
user: &User,
device_type: i32,
ip: &IpAddr,
conn: &DbConn,
) -> ApiResult<()> {
let Some(org_uuid) = CONFIG.sso_default_organization_uuid() else {
return Ok(());
};
@ -374,11 +381,14 @@ async fn enroll_user_in_default_organization(user: &User, conn: &DbConn) -> ApiR
}
let mut membership = Membership::new(user.uuid.clone(), org_id.clone(), None);
membership.status = MembershipStatus::Accepted as i32;
membership.status = MembershipStatus::Invited as i32;
membership.atype = MembershipType::User as i32;
membership.save(conn).await?;
info!("Added SSO user {} to default organization {} pending confirmation", user.uuid, org_id);
log_event(EventType::OrganizationUserInvited as i32, &membership.uuid, &org_id, &user.uuid, device_type, ip, conn)
.await;
info!("Invited SSO user {} to default organization {}", user.uuid, org_id);
Ok(())
}

Loading…
Cancel
Save