From 7cbea9af419e895c81a599f4f49ea9289d3f291c Mon Sep 17 00:00:00 2001 From: tom27052006 <83423411+tom27052006@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:59:57 +0200 Subject: [PATCH] Address SSO default organization review feedback --- src/api/core/accounts.rs | 44 ++++++++++++++++++++++++++++++++++- src/api/core/organizations.rs | 18 ++++++++------ src/api/identity.rs | 2 +- src/sso.rs | 26 ++++++++++++++------- 4 files changed, 73 insertions(+), 17 deletions(-) diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index 623edf24..979276d7 100644 --- a/src/api/core/accounts.rs +++ b/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, email_verification: bool, conn: async fn post_set_password(data: Json, 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, 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, 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 { Json(headers.user.to_json(&conn).await) diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index c7e79aed..50b4b693 100644 --- a/src/api/core/organizations.rs +++ b/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 diff --git a/src/api/identity.rs b/src/api/identity.rs index 1597698f..6de90b50 100644 --- a/src/api/identity.rs +++ b/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 } diff --git a/src/sso.rs b/src/sso.rs index 6fdb4ee2..da6df006 100644 --- a/src/sso.rs +++ b/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, sso_auth: SsoAuth, auth_user: OIDCAuthenticatedUser, + ip: &IpAddr, conn: &DbConn, ) -> ApiResult { 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(()) }