Browse Source

Merge 8e1f757ff5 into 0cefa4cca7

pull/7422/merge
Tom 6 days ago
committed by GitHub
parent
commit
ace1a4d378
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      .env.template
  2. 44
      src/api/core/accounts.rs
  3. 18
      src/api/core/organizations.rs
  4. 2
      src/api/identity.rs
  5. 6
      src/config.rs
  6. 75
      src/sso.rs

4
.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

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

@ -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

2
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
}

6
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")

75
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<SsoUser>,
sso_auth: SsoAuth,
auth_user: OIDCAuthenticatedUser,
ip: &IpAddr,
conn: &DbConn,
) -> ApiResult<AuthTokens> {
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<OrganizationId> {
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());
}
}

Loading…
Cancel
Save