From ff6ee1740712ca59b071d6991e7e80b5f95e39c4 Mon Sep 17 00:00:00 2001 From: Timshel Date: Wed, 3 Jan 2024 17:26:06 +0100 Subject: [PATCH] Allow Group/Organization mapping to trigger invitation --- .env.template | 6 ++ README.md | 35 ++++++- SSO.md | 2 + docker/keycloak/keycloak_setup.sh | 21 ++++ src/api/core/mod.rs | 2 +- src/api/core/organizations.rs | 156 +++++++++++++---------------- src/api/identity.rs | 2 + src/business/mod.rs | 1 + src/business/organization_logic.rs | 83 +++++++++++++++ src/config.rs | 4 + src/db/models/organization.rs | 37 +++++++ src/main.rs | 1 + src/sso.rs | 97 +++++++++++++++--- 13 files changed, 344 insertions(+), 103 deletions(-) create mode 100644 src/business/mod.rs create mode 100644 src/business/organization_logic.rs diff --git a/.env.template b/.env.template index c89ed5d303..5252e61aed 100644 --- a/.env.template +++ b/.env.template @@ -455,6 +455,12 @@ # SSO_ROLES_DEFAULT_TO_USER=true ## Id token path to read roles # SSO_ROLES_TOKEN_PATH=/resource_access/${SSO_CLIENT_ID}/roles +## Controls whether to add users to organization +# SSO_ORGANIZATIONS_INVITE=false +## Optional scope to retrieve user organizations +# SSO_ORGANIZATIONS_SCOPE=groups +## Id token path to read groups +# SSO_ORGANIZATIONS_TOKEN_PATH=/groups ## Client cache for discovery endpoint. Duration in seconds (0 to disable). # SSO_CLIENT_CACHE_EXPIRATION=0 ## Debug only, log all the tokens diff --git a/README.md b/README.md index 9e51a633f4..32986e1f33 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,11 @@ Goal is to help testing code for the SSO [PR](https://github.com/dani-garcia/vaultwarden/pull/3899). Based on [Timshel/sso-support](https://github.com/Timshel/vaultwarden/tree/sso-support) -:warning: Branch will be rebased and forced-pushed from time to time. :warning: +#### :warning: Branch will be rebased and forced-pushed when updated. :warning: ## Additionnal features -This branch now contain additionnal features not added to the SSO [PR](https://github.com/dani-garcia/vaultwarden/pull/3899) since it would slow even more it's review. +This branch now contain features not added to the SSO [PR](https://github.com/dani-garcia/vaultwarden/pull/3899) since it would slow even more it's review. ### Role mapping @@ -20,13 +20,42 @@ This feature is controlled by the following conf: - `SSO_ROLES_DEFAULT_TO_USER`: do not block login in case of missing or invalid roles, default is `true`. - `SSO_ROLES_TOKEN_PATH=/resource_access/${SSO_CLIENT_ID}/roles`: path to read roles in the Access token + +### Group/Organization invitation mapping + +Allow to invite user to existing Oganization if they are listed in the Access token. +If activated it will check if the token contain a list of potential Orgnaization. +If an Oganization with a matching name (case sensitive) is found it will the start the invitation process for this user. +It will use the email associated with the Organization to send further notifications (admin side). + +The flow look like this: + +- Decode the JWT Access token and check if a list of organization is present (default path is `/groups`). +- Check if an Organization with a matching name exist and the user is not part of it. +- if mail are activated invite the user to the Orgnization + - The user will need to click on the link in the mail he received + - A notification is sent tto he `email` associated with the Organization that a new user is ready to join + - An admin will have to validate the user to finalize the user joining the org. +- Otherwise just add the user to the Organization + - An admin will have to validate the user to confirm the user joining the org. + +One of the bonus of invitation is that if an organization define a specific password policy then it will apply to new user when they set their new master password. +If a user is part of two organizations then it will order them using the role of the user (`Owner`, `Admin`, `User` or `Manager` for now manager is last :() and return the password policy of the first one. + +This feature is controlled with the following conf: + +- `SSO_SCOPES`: Optional scope override if additionnal scopes are needed, default is `"email profile"` +- `SSO_ORGANIZATIONS_INVITE`: control if the mapping is done, default is `false` +- `SSO_ORGANIZATIONS_TOKEN_PATH`: path to read groups/organization in the Access token, default is `/groups` + + ## Docker Change the docker files to package both front-end from [Timshel/oidc_web_builds](https://github.com/Timshel/oidc_web_builds/releases). \ By default it will use the release which only make the `sso` button visible. -If you want to use the version which additionally change the default redirection to `/sso` and fix organization invitation to persist. +If you want to use the version with the additional features mentionned, default redirection to `/sso` and fix organization invitation. You need to pass an env variable: `-e SSO_FRONTEND='override'` (cf [start.sh](docker/start.sh)). Docker images available at: diff --git a/SSO.md b/SSO.md index 3e1aa02b73..742b435b1b 100644 --- a/SSO.md +++ b/SSO.md @@ -29,6 +29,8 @@ The following configurations are available - `SSO_ROLES_ENABLED`: control if the mapping is done, default is `false` - `SSO_ROLES_DEFAULT_TO_USER`: do not block login in case of missing or invalid roles, default is `true`. - `SSO_ROLES_TOKEN_PATH=/resource_access/${SSO_CLIENT_ID}/roles`: path to read roles in the Id token + - `SSO_ORGANIZATIONS_INVIT`: control if the mapping is done, default is `false` + - `SSO_ORGANIZATIONS_TOKEN_PATH`: path to read groups/organization in the Id token - `SSO_CLIENT_CACHE_EXPIRATION`: Cache calls to the discovery endpoint, duration in seconds, `0` to disable (default `0`); - `SSO_DEBUG_TOKENS`: Log all tokens for easier debugging (default `false`) diff --git a/docker/keycloak/keycloak_setup.sh b/docker/keycloak/keycloak_setup.sh index 074434188b..8eedf9a4dc 100755 --- a/docker/keycloak/keycloak_setup.sh +++ b/docker/keycloak/keycloak_setup.sh @@ -40,12 +40,31 @@ kcadm.sh create -r "$TEST_REALM" "client-scopes/$TEST_CLIENT_ROLES_SCOPE_ID/prot -s 'config."access.token.claim"=false' \ -s 'config."userinfo.token.claim"=true' +## Create group mapping client scope +TEST_GROUPS_CLIENT_SCOPE_ID=$(kcadm.sh create -r "$TEST_REALM" client-scopes -s name=groups -s protocol=openid-connect -i) +kcadm.sh create -r "$TEST_REALM" "client-scopes/$TEST_GROUPS_CLIENT_SCOPE_ID/protocol-mappers/models" \ + -s name=Groups \ + -s protocol=openid-connect \ + -s protocolMapper=oidc-group-membership-mapper \ + -s consentRequired=false \ + -s 'config."claim.name"=groups' \ + -s 'config."full.path"=false' \ + -s 'config."id.token.claim"=true' \ + -s 'config."access.token.claim"=true' \ + -s 'config."userinfo.token.claim"=true' + +TEST_GROUP_ID=$(kcadm.sh create -r "$TEST_REALM" groups -s name=Test -i) + TEST_CLIENT_ID=$(kcadm.sh create -r "$TEST_REALM" clients -s "name=VaultWarden" -s "clientId=$SSO_CLIENT_ID" -s "secret=$SSO_CLIENT_SECRET" -s "redirectUris=[\"$DOMAIN/*\"]" -i) ## ADD Role mapping scope kcadm.sh update -r "$TEST_REALM" "clients/$TEST_CLIENT_ID" --body "{\"optionalClientScopes\": [\"$TEST_CLIENT_ROLES_SCOPE_ID\"]}" kcadm.sh update -r "$TEST_REALM" "clients/$TEST_CLIENT_ID/optional-client-scopes/$TEST_CLIENT_ROLES_SCOPE_ID" +## ADD Group mapping scope +kcadm.sh update -r "$TEST_REALM" "clients/$TEST_CLIENT_ID" --body "{\"optionalClientScopes\": [\"$TEST_GROUPS_CLIENT_SCOPE_ID\"]}" +kcadm.sh update -r "$TEST_REALM" "clients/$TEST_CLIENT_ID/optional-client-scopes/$TEST_GROUPS_CLIENT_SCOPE_ID" + ## CREATE TEST ROLES kcadm.sh create -r "$TEST_REALM" "clients/$TEST_CLIENT_ID/roles" -s name=admin -s 'description=Admin role' kcadm.sh create -r "$TEST_REALM" "clients/$TEST_CLIENT_ID/roles" -s name=user -s 'description=Admin role' @@ -54,11 +73,13 @@ kcadm.sh create -r "$TEST_REALM" "clients/$TEST_CLIENT_ID/roles" -s name=user -s TEST_USER_ID=$(kcadm.sh create users -r "$TEST_REALM" -s "username=$TEST_USER" -s "email=$TEST_USER_MAIL" -s emailVerified=true -s enabled=true -i) kcadm.sh update -r "$TEST_REALM" "users/$TEST_USER_ID/reset-password" -s type=password -s "value=$TEST_USER_PASSWORD" -n +kcadm.sh update -r "$TEST_REALM" "users/$TEST_USER_ID/groups/$TEST_GROUP_ID" kcadm.sh add-roles -r "$TEST_REALM" --uusername "$TEST_USER" --cid "$TEST_CLIENT_ID" --rolename admin TEST_USER_2_ID=$(kcadm.sh create users -r "$TEST_REALM" -s "username=$TEST_USER_2" -s "email=$TEST_USER_2_MAIL" -s emailVerified=true -s enabled=true -i) kcadm.sh update -r "$TEST_REALM" "users/$TEST_USER_2_ID/reset-password" -s type=password -s "value=$TEST_USER_2_PASSWORD" -n +kcadm.sh update -r "$TEST_REALM" "users/$TEST_USER_2_ID/groups/$TEST_GROUP_ID" kcadm.sh add-roles -r "$TEST_REALM" --uusername "$TEST_USER_2" --cid "$TEST_CLIENT_ID" --rolename user touch $CANARY diff --git a/src/api/core/mod.rs b/src/api/core/mod.rs index 7712ea824a..95f9d8efa3 100644 --- a/src/api/core/mod.rs +++ b/src/api/core/mod.rs @@ -3,7 +3,7 @@ mod ciphers; mod emergency_access; mod events; mod folders; -mod organizations; +pub mod organizations; mod public; mod sends; pub mod two_factor; diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index d711f3208d..4d464c87d9 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -9,6 +9,7 @@ use crate::{ EmptyResult, JsonResult, JsonUpcase, JsonUpcaseVec, JsonVec, Notify, PasswordOrOtpData, UpdateType, }, auth::{decode_invite, AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OwnerHeaders}, + business::organization_logic, db::{models::*, DbConn}, error::Error, mail, @@ -307,12 +308,19 @@ async fn get_user_collections(headers: Headers, mut conn: DbConn) -> Json } // Called during the SSO enrollment -// The `_identifier` should be the harcoded value returned by `get_org_domain_sso_details` -// The returned `Id` will then be passed to `get_policy_master_password` which will mainly ignore it -#[get("/organizations/<_identifier>/auto-enroll-status")] -fn get_auto_enroll_status(_identifier: &str) -> JsonResult { +// We return the org_id if it exists ortherwise we return the first associated with the user +#[get("/organizations//auto-enroll-status")] +async fn get_auto_enroll_status(identifier: &str, headers: Headers, mut conn: DbConn) -> JsonResult { + let org_id = match Organization::find_by_name(identifier, &mut conn).await.map(|o| o.uuid) { + Some(org_id) => org_id, + None => UserOrganization::find_main_user_org(&headers.user.uuid, &mut conn) + .await + .map(|uo| uo.org_uuid) + .unwrap_or_else(|| "null".to_string()), + }; + Ok(Json(json!({ - "Id": "_", + "Id": org_id, "ResetPasswordEnabled": false, // Not implemented }))) } @@ -790,13 +798,25 @@ async fn _get_org_details(org_id: &str, host: &str, user_uuid: &str, conn: &mut json!(ciphers_json) } -// Endpoint called when the user select SSO login (body: `{ "email": "" }`). +#[derive(Deserialize)] +#[allow(non_snake_case)] +struct OrgDomainDetails { + Email: String, +} + // Returning a Domain/Organization here allow to prefill it and prevent prompting the user -// VaultWarden sso login is not linked to Org so we set a dummy value. -#[post("/organizations/domain/sso/details")] -fn get_org_domain_sso_details() -> JsonResult { +// So we either return an Org name associated to the user or a dummy value. +#[post("/organizations/domain/sso/details", data = "")] +async fn get_org_domain_sso_details(data: JsonUpcase, mut conn: DbConn) -> JsonResult { + let data: OrgDomainDetails = data.into_inner().data; + + let identifier = match Organization::find_main_org_user_email(&data.Email, &mut conn).await { + Some(org) => org.name, + None => crate::sso::FAKE_IDENTIFIER.to_string(), + }; + Ok(Json(json!({ - "organizationIdentifier": "vaultwarden", + "organizationIdentifier": identifier, "ssoAvailable": CONFIG.sso_enabled() }))) } @@ -868,10 +888,10 @@ async fn post_org_keys( #[derive(Deserialize)] #[allow(non_snake_case)] -struct CollectionData { - Id: String, - ReadOnly: bool, - HidePasswords: bool, +pub struct CollectionData { + pub Id: String, + pub ReadOnly: bool, + pub HidePasswords: bool, } #[derive(Deserialize)] @@ -894,7 +914,7 @@ async fn send_invite( let data: InviteData = data.into_inner().data; let new_type = match UserOrgType::from_str(&data.Type.into_string()) { - Some(new_type) => new_type as i32, + Some(new_type) => new_type, None => err!("Invalid type"), }; @@ -902,9 +922,15 @@ async fn send_invite( err!("Only Owners can invite Managers, Admins or Owners") } + let org = match Organization::find_by_uuid(org_id, &mut conn).await { + Some(org) => org, + None => err!("Error looking up organization"), + }; + + let collections = data.Collections.into_iter().flatten().collect(); + for email in data.Emails.iter() { let email = email.to_lowercase(); - let mut user_org_status = UserOrgStatus::Invited as i32; let user = match User::find_by_mail(&email, &mut conn).await { None => { if !CONFIG.invitations_allowed() { @@ -927,70 +953,24 @@ async fn send_invite( Some(user) => { if UserOrganization::find_by_user_and_org(&user.uuid, org_id, &mut conn).await.is_some() { err!(format!("User already in organization: {email}")) - } else { - // automatically accept existing users if mail is disabled - if !CONFIG.mail_enabled() && !user.password_hash.is_empty() { - user_org_status = UserOrgStatus::Accepted as i32; - } - user } + user } }; - let mut new_user = - UserOrganization::new(user.uuid.clone(), String::from(org_id), Some(headers.user.email.clone())); - let access_all = data.AccessAll.unwrap_or(false); - new_user.access_all = access_all; - new_user.atype = new_type; - new_user.status = user_org_status; - - // If no accessAll, add the collections received - if !access_all { - for col in data.Collections.iter().flatten() { - match Collection::find_by_uuid_and_org(&col.Id, org_id, &mut conn).await { - None => err!("Collection not found in Organization"), - Some(collection) => { - CollectionUser::save(&user.uuid, &collection.uuid, col.ReadOnly, col.HidePasswords, &mut conn) - .await?; - } - } - } - } - - new_user.save(&mut conn).await?; - - for group in data.Groups.iter() { - let mut group_entry = GroupUser::new(String::from(group), user.uuid.clone()); - group_entry.save(&mut conn).await?; - } - - log_event( - EventType::OrganizationUserInvited as i32, - &new_user.uuid, - org_id, - &headers.user.uuid, - headers.device.atype, - &headers.ip.ip, + organization_logic::invite( + &user, + &headers.device, + &headers.ip, + &org, + new_type, + &data.Groups, + data.AccessAll.unwrap_or(false), + &collections, + headers.user.email.clone(), &mut conn, ) - .await; - - if CONFIG.mail_enabled() { - let org_name = match Organization::find_by_uuid(org_id, &mut conn).await { - Some(org) => org.name, - None => err!("Error looking up organization"), - }; - - mail::send_invite( - &email, - &user.uuid, - Some(String::from(org_id)), - Some(new_user.uuid), - &org_name, - Some(headers.user.email.clone()), - ) - .await?; - } + .await?; } Ok(()) @@ -1706,20 +1686,24 @@ async fn list_policies_invited_user(org_id: &str, userId: &str, mut conn: DbConn } // Called during the SSO enrollment. +// Return the org policy if it exists, otherwise use the default one. #[get("/organizations//policies/master-password", rank = 1)] -fn get_policy_master_password(org_id: &str, _headers: Headers) -> JsonResult { - let data = match CONFIG.sso_master_password_policy() { - Some(policy) => policy, - None => "null".to_string(), - }; +async fn get_policy_master_password(org_id: &str, _headers: Headers, mut conn: DbConn) -> JsonResult { + let policy = + OrgPolicy::find_by_org_and_type(org_id, OrgPolicyType::MasterPassword, &mut conn).await.unwrap_or_else(|| { + let data = match CONFIG.sso_master_password_policy() { + Some(policy) => policy, + None => "null".to_string(), + }; - let policy = OrgPolicy { - uuid: String::from(org_id), - org_uuid: String::from(org_id), - atype: OrgPolicyType::MasterPassword as i32, - enabled: CONFIG.sso_master_password_policy().is_some(), - data, - }; + OrgPolicy { + uuid: String::from(org_id), + org_uuid: String::from(org_id), + atype: OrgPolicyType::MasterPassword as i32, + enabled: CONFIG.sso_master_password_policy().is_some(), + data, + } + }); Ok(Json(policy.to_json())) } diff --git a/src/api/identity.rs b/src/api/identity.rs index 0f4a5a8d99..93f2e7fc87 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -264,6 +264,8 @@ async fn _sso_login( // Set the user_uuid here to be passed back used for event logging. *user_uuid = Some(user.uuid.clone()); + sso::sync_groups(&user, &device, ip, &auth_user.groups, conn).await?; + if auth_user.is_admin() { info!("User {} logged with admin cookie", user.email); cookies.add(admin::create_admin_cookie()); diff --git a/src/business/mod.rs b/src/business/mod.rs new file mode 100644 index 0000000000..ee5cb8e469 --- /dev/null +++ b/src/business/mod.rs @@ -0,0 +1 @@ +pub mod organization_logic; diff --git a/src/business/organization_logic.rs b/src/business/organization_logic.rs new file mode 100644 index 0000000000..aa47bd6263 --- /dev/null +++ b/src/business/organization_logic.rs @@ -0,0 +1,83 @@ +use crate::{ + api::{core::log_event, core::organizations::CollectionData, ApiResult}, + auth::ClientIp, + db::models::*, + db::DbConn, + mail, CONFIG, +}; + +#[allow(clippy::too_many_arguments)] +pub async fn invite( + user: &User, + device: &Device, + ip: &ClientIp, + org: &Organization, + user_org_type: UserOrgType, + groups: &Vec, + access_all: bool, + collections: &Vec, + invited_by_email: String, + conn: &mut DbConn, +) -> ApiResult<()> { + let mut user_org_status = UserOrgStatus::Invited; + + // automatically accept existing users if mail is disabled + if !user.password_hash.is_empty() && !CONFIG.mail_enabled() { + user_org_status = UserOrgStatus::Accepted; + } + + let mut new_uo = UserOrganization::new(user.uuid.clone(), org.uuid.clone(), Some(invited_by_email.clone())); + new_uo.access_all = access_all; + new_uo.atype = user_org_type as i32; + new_uo.status = user_org_status as i32; + + // If no accessAll, add the collections received + if !access_all { + for col in collections { + match Collection::find_by_uuid_and_org(&col.Id, &org.uuid, conn).await { + None => err!("Collection not found in Organization"), + Some(collection) => { + CollectionUser::save(&user.uuid, &collection.uuid, col.ReadOnly, col.HidePasswords, conn).await?; + } + } + } + } + + new_uo.save(conn).await?; + + for group in groups { + let mut group_entry = GroupUser::new(group.clone(), user.uuid.clone()); + group_entry.save(conn).await?; + } + + log_event( + EventType::OrganizationUserInvited as i32, + &new_uo.uuid, + &org.uuid, + &user.uuid, + device.atype, + &ip.ip, + conn, + ) + .await; + + if CONFIG.mail_enabled() { + match user_org_status { + UserOrgStatus::Invited => { + mail::send_invite( + &user.email, + &user.uuid, + Some(org.uuid.clone()), + Some(new_uo.uuid), + &org.name, + new_uo.invited_by_email.clone(), + ) + .await? + } + UserOrgStatus::Accepted => mail::send_invite_accepted(&user.email, &invited_by_email, &org.name).await?, + UserOrgStatus::Revoked | UserOrgStatus::Confirmed => (), + } + } + + Ok(()) +} diff --git a/src/config.rs b/src/config.rs index 04cf193385..f711922819 100644 --- a/src/config.rs +++ b/src/config.rs @@ -647,6 +647,10 @@ make_config! { sso_roles_default_to_user: bool, false, def, true; /// Id token path to read roles sso_roles_token_path: String, false, auto, |c| format!("/resource_access/{}/roles", c.sso_client_id); + /// Invite users to Organizations + sso_organizations_invite: bool, false, def, false; + /// Id token path to read Organization/Groups + sso_organizations_token_path: String, false, def, "/groups".to_string(); /// Client cache for discovery endpoint. Duration in seconds (0 or less to disable). sso_client_cache_expiration: u64, true, def, 0; /// Log all tokens diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index 6b5c966c74..69b8e7e519 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -48,6 +48,7 @@ db_object! { } // https://github.com/bitwarden/server/blob/b86a04cef9f1e1b82cf18e49fc94e017c641130c/src/Core/Enums/OrganizationUserStatusType.cs +#[derive(Copy, Clone)] pub enum UserOrgStatus { Revoked = -1, Invited = 0, @@ -335,11 +336,36 @@ impl Organization { }} } + pub async fn find_by_name(name: &str, conn: &mut DbConn) -> Option { + db_run! { conn: { + organizations::table + .filter(organizations::name.eq(name)) + .first::(conn) + .ok().from_db() + }} + } + pub async fn get_all(conn: &mut DbConn) -> Vec { db_run! { conn: { organizations::table.load::(conn).expect("Error loading organizations").from_db() }} } + + pub async fn find_main_org_user_email(user_email: &str, conn: &mut DbConn) -> Option { + let lower_mail = user_email.to_lowercase(); + + db_run! { conn: { + organizations::table + .inner_join(users_organizations::table.on(users_organizations::org_uuid.eq(organizations::uuid))) + .inner_join(users::table.on(users::uuid.eq(users_organizations::user_uuid))) + .filter(users::email.eq(lower_mail)) + .filter(users_organizations::status.ne(UserOrgStatus::Revoked as i32)) + .order(users_organizations::atype.asc()) + .select(organizations::all_columns) + .first::(conn) + .ok().from_db() + }} + } } impl UserOrganization { @@ -856,6 +882,17 @@ impl UserOrganization { .first::(conn).ok().from_db() }} } + + pub async fn find_main_user_org(user_uuid: &str, conn: &mut DbConn) -> Option { + db_run! { conn: { + users_organizations::table + .filter(users_organizations::user_uuid.eq(user_uuid)) + .filter(users_organizations::status.ne(UserOrgStatus::Revoked as i32)) + .order(users_organizations::atype.asc()) + .first::(conn) + .ok().from_db() + }} + } } impl OrganizationApiKey { diff --git a/src/main.rs b/src/main.rs index 1da3d22185..b1a078c4ff 100644 --- a/src/main.rs +++ b/src/main.rs @@ -46,6 +46,7 @@ mod auth; mod config; mod crypto; #[macro_use] +mod business; mod db; mod mail; mod ratelimit; diff --git a/src/sso.rs b/src/sso.rs index ed02bba179..b7ec165a9e 100644 --- a/src/sso.rs +++ b/src/sso.rs @@ -2,6 +2,7 @@ use chrono::Utc; use regex::Regex; use serde::de::DeserializeOwned; use std::borrow::Cow; +use std::collections::HashMap; use std::time::Duration; use url::Url; @@ -18,16 +19,20 @@ use openidconnect::{ }; use crate::{ + api::core::organizations::CollectionData, api::ApiResult, auth, - auth::{AuthMethod, AuthMethodScope, AuthTokens, TokenWrapper, BW_EXPIRATION, DEFAULT_REFRESH_VALIDITY}, + auth::{AuthMethod, AuthMethodScope, AuthTokens, ClientIp, TokenWrapper, BW_EXPIRATION, DEFAULT_REFRESH_VALIDITY}, + business::organization_logic, db::{ - models::{Device, SsoNonce, User}, + models::{Device, EventType, Organization, SsoNonce, User, UserOrgType, UserOrganization}, DbConn, }, CONFIG, }; +pub static FAKE_IDENTIFIER: &str = "VaultWarden"; + static AC_CACHE: Lazy> = Lazy::new(|| Cache::builder().max_capacity(1000).time_to_live(Duration::from_secs(10 * 60)).build()); @@ -260,6 +265,7 @@ pub async fn authorize_url(state: String, client_id: &str, raw_redirect_uri: &st #[derive(Debug)] struct AdditionnalClaims { role: Option, + groups: Vec, } #[derive(Clone, Debug, Ord, PartialOrd, Eq, PartialEq, Serialize, Deserialize)] @@ -280,6 +286,7 @@ pub struct AuthenticatedUser { pub email_verified: Option, pub user_name: Option, pub role: Option, + pub groups: Vec, } impl AuthenticatedUser { @@ -298,7 +305,7 @@ pub struct UserInformation { } // Errors are logged but will return None -fn roles(email: &str, token: &serde_json::Value) -> Option { +fn roles_claim(email: &str, token: &serde_json::Value) -> Option { if let Some(json_roles) = token.pointer(&CONFIG.sso_roles_token_path()) { match serde_json::from_value::>(json_roles.clone()) { Ok(mut roles) => { @@ -316,24 +323,47 @@ fn roles(email: &str, token: &serde_json::Value) -> Option { } } +// Errors are logged but will return an empty Vec +fn groups_claim(email: &str, token: &serde_json::Value) -> Vec { + if let Some(json_groups) = token.pointer(&CONFIG.sso_organizations_token_path()) { + match serde_json::from_value::>(json_groups.clone()) { + Ok(groups) => groups, + Err(err) => { + error!("Failed to parse user ({email}) groups: {err}"); + Vec::new() + } + } + } else { + debug!("No groups in {email} id_token at {}", &CONFIG.sso_organizations_token_path()); + Vec::new() + } +} + // Trying to conditionnally read additionnal configurable claims using openidconnect appear nightmarish // So we just decode the token again as a JsValue fn additional_claims(email: &str, token: &str) -> ApiResult { let mut role = None; + let mut groups = Vec::new(); - if CONFIG.sso_roles_enabled() { + if CONFIG.sso_roles_enabled() || CONFIG.sso_organizations_invite() { match insecure_decode::("id_token", token) { Err(err) => err!(format!("Could not decode access token: {:?}", err)), Ok(claims) => { - role = roles(email, &claims); - if !CONFIG.sso_roles_default_to_user() && role.is_none() { - info!("User {email} failed to login due to missing/invalid role"); - err!( - "Invalid user role. Contact your administrator", - ErrorEvent { - event: EventType::UserFailedLogIn - } - ) + if CONFIG.sso_roles_enabled() { + role = roles_claim(email, &claims); + if !CONFIG.sso_roles_default_to_user() && role.is_none() { + info!("User {email} failed to login due to missing/invalid role"); + err!( + "Invalid user role. Contact your administrator", + ErrorEvent { + event: EventType::UserFailedLogIn + } + ) + } + } + + if CONFIG.sso_organizations_invite() { + groups = groups_claim(email, &claims); } } } @@ -341,6 +371,7 @@ fn additional_claims(email: &str, token: &str) -> ApiResult { Ok(AdditionnalClaims { role, + groups, }) } @@ -459,6 +490,7 @@ pub async fn exchange_code(wrapped_code: &str, conn: &mut DbConn) -> ApiResult err!("No token present while in SSO"), } } + +pub async fn sync_groups( + user: &User, + device: &Device, + ip: &ClientIp, + groups: &Vec, + conn: &mut DbConn, +) -> ApiResult<()> { + if CONFIG.sso_organizations_invite() { + let db_user_orgs = UserOrganization::find_any_state_by_user(&user.uuid, conn).await; + let user_orgs = db_user_orgs.iter().map(|uo| (uo.org_uuid.clone(), uo)).collect::>(); + + let org_groups: Vec = vec![]; + let org_collections: Vec = vec![]; + + for group in groups { + if let Some(org) = Organization::find_by_name(group, conn).await { + if user_orgs.get(&org.uuid).is_none() { + info!("Invitation to {} organization sent to {}", group, user.email); + organization_logic::invite( + user, + device, + ip, + &org, + UserOrgType::User, + &org_groups, + true, + &org_collections, + org.billing_email.clone(), + conn, + ) + .await?; + } + } + } + } + + Ok(()) +}