diff --git a/.env.template b/.env.template index 92362088..36a3c9d4 100644 --- a/.env.template +++ b/.env.template @@ -532,6 +532,10 @@ ## Allow unknown email verification status. Allowing this with `SSO_SIGNUPS_MATCH_EMAIL=true` open potential account takeover. # SSO_ALLOW_UNKNOWN_EMAIL_VERIFICATION=false +## Automatically add users on their first SSO sign-in as accepted members of this organization. +## An administrator must confirm them and assign collections or groups. No invitation email is sent. +# SSO_DEFAULT_ORGANIZATION_UUID=00000000-0000-0000-0000-000000000000 + ## Base URL of the OIDC server (auto-discovery is used) ## - Should not include the `/.well-known/openid-configuration` part and no trailing `/` ## - ${SSO_AUTHORITY}/.well-known/openid-configuration should return a json document: https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationResponse diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index cd280bc7..1f93eded 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -22,8 +22,8 @@ use crate::{ models::{ AuthRequest, AuthRequestId, Cipher, CipherId, Device, DeviceId, DeviceType, DeviceWithAuthRequest, EmergencyAccess, EmergencyAccessId, EventType, Folder, FolderId, Invitation, KeyId, Membership, - MembershipId, OrgPolicy, OrgPolicyType, Organization, OrganizationId, Send, SendId, User, UserId, - UserKdfType, + MembershipId, MembershipStatus, OrgPolicy, OrgPolicyType, Organization, OrganizationId, Send, SendId, User, + UserId, UserKdfType, }, }, mail, @@ -445,6 +445,12 @@ pub async fn register(data: Json, conn: DbConn) -> JsonResult { 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 = crate::sso::default_organization_id()?; + 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() || !user.password_hash.is_empty() { err!("Account already initialized, cannot set password") @@ -473,6 +479,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 { @@ -487,21 +494,56 @@ async fn post_set_password(data: Json, headers: Headers, conn: accept_org_invite(&user, membership, None, &conn).await?; } - if CONFIG.mail_enabled() { + let mail_enabled = CONFIG.mail_enabled(); + if mail_enabled { mail::send_welcome(&user.email.to_lowercase()).await?; - } else { - Membership::accept_user_invitations(&user.uuid, &conn).await?; } log_user_event(EventType::UserChangedPassword, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; 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?; + } + + // Accept any remaining invitations only after the default organization policy checks above. + if !mail_enabled { + Membership::accept_user_invitations(&user.uuid, &conn).await?; + } + Ok(Json(json!({ "object": "set-password", }))) } +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 3328423e..dee01f98 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -865,18 +865,18 @@ 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` +// Return the configured default organization so its policies apply during SSO enrollment; otherwise return a dummy. +// In use since `v2025.6.0`; clients appear 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 = crate::sso::default_organization_id()? + .map_or_else(|| FAKE_SSO_IDENTIFIER.to_owned(), |org_id| org_id.to_string()); + Ok(Json(json!({ "object": "list", "data": [{ - "organizationIdentifier": FAKE_SSO_IDENTIFIER, - // These appear to be unused - "organizationName": FAKE_SSO_IDENTIFIER, + "organizationIdentifier": organization_identifier, + "organizationName": organization_identifier, "domainName": CONFIG.domain() }], "continuationToken": null diff --git a/src/api/identity.rs b/src/api/identity.rs index 146cec4c..302d5308 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -363,7 +363,7 @@ async fn sso_login(data: ConnectData, user_id: &mut Option, conn: &DbCon *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/config.rs b/src/config.rs index 55d08259..127b5e42 100644 --- a/src/config.rs +++ b/src/config.rs @@ -845,6 +845,8 @@ make_config! { sso_signups_match_email: bool, true, def, true; /// Allow unknown email verification status |> Allowing this with `SSO_SIGNUPS_MATCH_EMAIL=true` open potential account takeover. sso_allow_unknown_email_verification: bool, true, def, false; + /// Default organization UUID |> Automatically add users on their first SSO sign-in as accepted members of this organization. An administrator must confirm them and assign collections or groups. No invitation email is sent. + sso_default_organization_uuid: String, true, option; /// Client ID sso_client_id: String, true, def, String::new(); /// Client Key @@ -1157,6 +1159,10 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { validate_sso_master_password_policy(cfg.sso_master_password_policy.as_ref())?; } + if let Some(org_uuid) = &cfg.sso_default_organization_uuid { + crate::sso::normalize_organization_uuid(org_uuid)?; + } + if cfg._enable_yubico { if cfg.yubico_client_id.is_some() != cfg.yubico_secret_key.is_some() { err!("Both `YUBICO_CLIENT_ID` and `YUBICO_SECRET_KEY` must be set for Yubikey OTP support") diff --git a/src/sso.rs b/src/sso.rs index cc3b51f1..47b5acef 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}; @@ -6,12 +6,15 @@ 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, OIDCAuthenticatedUser, SsoAuth, SsoUser, User}, + models::{ + Device, EventType, Membership, MembershipStatus, OIDCAuthenticatedUser, Organization, OrganizationId, + SsoAuth, SsoUser, User, + }, }, sso_client::Client, }; @@ -322,6 +325,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, @@ -329,11 +333,14 @@ 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() { + invite_user_to_default_organization(user, device.atype, ip, conn).await?; + let user_sso = SsoUser { user_uuid: user.uuid.clone(), identifier: auth_user.identifier.clone(), @@ -360,6 +367,46 @@ pub async fn redeem( } } +async fn invite_user_to_default_organization( + user: &User, + device_type: i32, + ip: &IpAddr, + conn: &DbConn, +) -> ApiResult<()> { + let Some(org_id) = default_organization_id()? else { + return Ok(()); + }; + + if Membership::find_by_user_and_org(&user.uuid, &org_id, conn).await.is_some() { + return Ok(()); + } + + if Organization::find_by_uuid(&org_id, conn).await.is_none() { + err!("The organization configured in `SSO_DEFAULT_ORGANIZATION_UUID` does not exist") + } + + let mut membership = Membership::new(user.uuid.clone(), org_id.clone(), None); + membership.status = MembershipStatus::Invited as i32; + membership.save(conn).await?; + + log_event(EventType::OrganizationUserInvited, &membership.uuid, &org_id, &user.uuid, device_type, ip, conn).await; + + info!("Invited SSO user {} to default organization {}", user.uuid, org_id); + Ok(()) +} + +pub(crate) fn default_organization_id() -> ApiResult> { + CONFIG.sso_default_organization_uuid().as_deref().map(normalize_organization_uuid).transpose() +} + +// Stored organization uuids are always lowercase hyphenated and compared as strings. +pub(crate) fn normalize_organization_uuid(org_uuid: &str) -> ApiResult { + let Ok(parsed) = uuid::Uuid::parse_str(org_uuid) else { + err!("`SSO_DEFAULT_ORGANIZATION_UUID` must be a valid UUID") + }; + Ok(OrganizationId::from(parsed.to_string())) +} + // We always return a refresh_token (with no refresh_token some secrets are not displayed in the web front). // If there is no SSO refresh_token, we keep the access_token to be able to call user_info to check for validity pub fn create_auth_tokens(