diff --git a/.env.template b/.env.template index 9fc29989..269ddf58 100644 --- a/.env.template +++ b/.env.template @@ -524,6 +524,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 0cb4d3c0..7280aa50 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 989ca47d..b2f4b97d 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -917,18 +917,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 23411dc7..20069a96 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -354,7 +354,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/config.rs b/src/config.rs index d5b50146..36ab6cc9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -821,6 +821,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 @@ -1114,6 +1116,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 01fbd906..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,12 +7,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, MembershipType, OIDCAuthenticatedUser, Organization, + OrganizationId, SsoAuth, SsoUser, User, + }, }, sso_client::Client, }; @@ -316,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, @@ -323,11 +327,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(), @@ -354,6 +361,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_uuid) = CONFIG.sso_default_organization_uuid() else { + return Ok(()); + }; + let org_id = normalize_organization_uuid(&org_uuid)?; + + 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.atype = MembershipType::User as i32; + membership.save(conn).await?; + + 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(()) +} + +// `Uuid::parse_str` also accepts non-canonical forms (uppercase, braced, without hyphens), +// while 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( @@ -471,3 +518,25 @@ pub async fn exchange_refresh_token( None => err!("No token present while in SSO"), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalizes_organization_uuid_to_canonical_form() { + for input in [ + "1B2C3D4E-5F60-7182-93A4-B5C6D7E8F901", + "{1b2c3d4e-5f60-7182-93a4-b5c6d7e8f901}", + "1b2c3d4e5f60718293a4b5c6d7e8f901", + ] { + let org_id = normalize_organization_uuid(input).expect("valid UUID form should be accepted"); + assert_eq!(org_id.to_string(), "1b2c3d4e-5f60-7182-93a4-b5c6d7e8f901"); + } + } + + #[test] + fn rejects_invalid_organization_uuid() { + assert!(normalize_organization_uuid("not-a-uuid").is_err()); + } +}