diff --git a/.env.template b/.env.template index 0d922774..4be1d8a1 100644 --- a/.env.template +++ b/.env.template @@ -501,6 +501,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/config.rs b/src/config.rs index 49281b6c..5d1d5a93 100644 --- a/src/config.rs +++ b/src/config.rs @@ -805,6 +805,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 @@ -1086,6 +1088,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..6fdb4ee2 100644 --- a/src/sso.rs +++ b/src/sso.rs @@ -12,7 +12,10 @@ use crate::{ auth::{AuthMethod, AuthTokens, BW_EXPIRATION, DEFAULT_REFRESH_VALIDITY, TokenWrapper}, db::{ DbConn, - models::{Device, OIDCAuthenticatedUser, SsoAuth, SsoUser, User}, + models::{ + Device, Membership, MembershipStatus, MembershipType, OIDCAuthenticatedUser, Organization, OrganizationId, + SsoAuth, SsoUser, User, + }, }, sso_client::Client, }; @@ -328,6 +331,8 @@ pub async fn redeem( sso_auth.delete(conn).await?; if sso_user.is_none() { + enroll_user_in_default_organization(user, conn).await?; + let user_sso = SsoUser { user_uuid: user.uuid.clone(), identifier: auth_user.identifier.clone(), @@ -354,6 +359,38 @@ pub async fn redeem( } } +async fn enroll_user_in_default_organization(user: &User, 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::Accepted 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); + 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 +508,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()); + } +}