mirror of
https://github.com/dani-garcia/vaultwarden.wiki.git
synced 2026-10-23 02:38:11 +03:00
- Attachments: reject a declared v2 attachment size above the upload limit, keep display sizes within the largest unit, and make download links valid for 1 minute like upstream. - Ciphers: moving a personal cipher into an organization needs collection write access or full access, like upstream. - Events: /events/collect only accepts the client event types upstream accepts. - Client IP: for list headers like X-Forwarded-For, use the rightmost address that isn't in IP_HEADER_TRUSTED_PROXIES, reading every occurrence. - Prelogin: unknown emails get stable KDF settings from upstream's list. - Login with device: an auth request can be used for one login, like upstream. - Directory Connector import: never revokes, restores or removes Owners, like upstream. - SSO: check the PKCE verifier again when the code exchange is reused during 2FA, and accept the same redirect URIs as upstream (desktop localhost callback for AppImage and dev builds, CLI on ports 8065-8070). - Identity: send the merged master password policy in the 2FA response and use PascalCase policy keys in identity responses, like upstream. Organization API key login no longer needs device fields. Recovery code login normalises the code, uses upstream's error message, logs a failed 2FA event and sends a "two-step login recovered" email. - Add GET /api/accounts/keys. - Return errors instead of panicking on malformed event dates, cipher data, key rotation cipher ids, bulk confirm entries and YubiKey OTPs.
622 lines
19 KiB
Rust
622 lines
19 KiB
Rust
use chrono::{NaiveDateTime, TimeDelta, Utc};
|
|
use derive_more::{AsRef, Deref, Display, From};
|
|
use diesel::prelude::*;
|
|
use serde_json::Value;
|
|
|
|
use crate::{
|
|
CONFIG,
|
|
api::EmptyResult,
|
|
crypto,
|
|
db::{
|
|
DbConn,
|
|
models::DeviceId,
|
|
schema::{invitations, sso_users, twofactor_incomplete, users},
|
|
},
|
|
error::MapResult,
|
|
sso::OIDCIdentifier,
|
|
util::{format_date, get_uuid, retry},
|
|
};
|
|
use macros::UuidFromParam;
|
|
|
|
use super::{
|
|
Cipher, Device, EmergencyAccess, Favorite, Folder, Membership, MembershipType, TwoFactor, TwoFactorIncomplete,
|
|
};
|
|
|
|
#[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)]
|
|
#[diesel(table_name = users)]
|
|
#[diesel(treat_none_as_null = true)]
|
|
#[diesel(primary_key(uuid))]
|
|
pub struct User {
|
|
pub uuid: UserId,
|
|
pub enabled: bool,
|
|
pub created_at: NaiveDateTime,
|
|
pub updated_at: NaiveDateTime,
|
|
pub verified_at: Option<NaiveDateTime>,
|
|
pub last_verifying_at: Option<NaiveDateTime>,
|
|
pub login_verify_count: i32,
|
|
|
|
pub email: String,
|
|
pub email_new: Option<String>,
|
|
pub email_new_token: Option<String>,
|
|
pub name: String,
|
|
|
|
pub password_hash: Vec<u8>,
|
|
pub salt: Vec<u8>,
|
|
pub password_iterations: i32,
|
|
pub password_hint: Option<String>,
|
|
|
|
pub akey: String,
|
|
pub private_key: Option<String>,
|
|
pub public_key: Option<String>,
|
|
|
|
#[diesel(column_name = "totp_secret")] // Note, this is only added to the UserDb structs, not to User
|
|
_totp_secret: Option<String>,
|
|
pub totp_recover: Option<String>,
|
|
|
|
pub security_stamp: String,
|
|
pub stamp_exception: Option<String>,
|
|
|
|
pub equivalent_domains: String,
|
|
pub excluded_globals: String,
|
|
|
|
pub client_kdf_type: i32,
|
|
pub client_kdf_iter: i32,
|
|
pub client_kdf_memory: Option<i32>,
|
|
pub client_kdf_parallelism: Option<i32>,
|
|
|
|
pub api_key: Option<String>,
|
|
|
|
pub avatar_color: Option<String>,
|
|
|
|
pub external_id: Option<String>, // Todo: Needs to be removed in the future, this is not used anymore.
|
|
|
|
pub key_id: Option<KeyId>,
|
|
}
|
|
|
|
#[derive(Identifiable, Queryable, Insertable)]
|
|
#[diesel(table_name = invitations)]
|
|
#[diesel(primary_key(email))]
|
|
pub struct Invitation {
|
|
pub email: String,
|
|
}
|
|
|
|
#[derive(Identifiable, Queryable, Insertable, Selectable)]
|
|
#[diesel(table_name = sso_users)]
|
|
#[diesel(primary_key(user_uuid))]
|
|
pub struct SsoUser {
|
|
pub user_uuid: UserId,
|
|
pub identifier: OIDCIdentifier,
|
|
}
|
|
|
|
pub enum UserKdfType {
|
|
Pbkdf2 = 0,
|
|
Argon2id = 1,
|
|
}
|
|
|
|
enum UserStatus {
|
|
Enabled = 0,
|
|
Invited = 1,
|
|
_Disabled = 2,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
pub struct UserStampException {
|
|
pub routes: Vec<String>,
|
|
pub security_stamp: String,
|
|
pub expire: i64,
|
|
}
|
|
|
|
/// Local methods
|
|
impl User {
|
|
pub const CLIENT_KDF_TYPE_DEFAULT: i32 = UserKdfType::Pbkdf2 as i32;
|
|
pub const CLIENT_KDF_ITER_DEFAULT: i32 = 600_000;
|
|
|
|
pub fn new(email: &str, name: Option<String>) -> Self {
|
|
let now = Utc::now().naive_utc();
|
|
let email = email.to_lowercase();
|
|
|
|
Self {
|
|
uuid: UserId(get_uuid()),
|
|
enabled: true,
|
|
created_at: now,
|
|
updated_at: now,
|
|
verified_at: None,
|
|
last_verifying_at: None,
|
|
login_verify_count: 0,
|
|
name: name.unwrap_or(email.clone()),
|
|
email,
|
|
akey: String::new(),
|
|
email_new: None,
|
|
email_new_token: None,
|
|
|
|
password_hash: Vec::new(),
|
|
salt: crypto::get_random_bytes::<64>().to_vec(),
|
|
password_iterations: CONFIG.password_iterations(),
|
|
|
|
security_stamp: get_uuid(),
|
|
stamp_exception: None,
|
|
|
|
password_hint: None,
|
|
private_key: None,
|
|
public_key: None,
|
|
|
|
_totp_secret: None,
|
|
totp_recover: None,
|
|
|
|
equivalent_domains: "[]".to_owned(),
|
|
excluded_globals: "[]".to_owned(),
|
|
|
|
client_kdf_type: Self::CLIENT_KDF_TYPE_DEFAULT,
|
|
client_kdf_iter: Self::CLIENT_KDF_ITER_DEFAULT,
|
|
client_kdf_memory: None,
|
|
client_kdf_parallelism: None,
|
|
|
|
api_key: None,
|
|
|
|
avatar_color: None,
|
|
|
|
external_id: None, // Todo: Needs to be removed in the future, this is not used anymore.
|
|
|
|
key_id: None,
|
|
}
|
|
}
|
|
|
|
pub fn check_valid_password(&self, password: &str) -> bool {
|
|
crypto::verify_password_hash(
|
|
password.as_bytes(),
|
|
&self.salt,
|
|
&self.password_hash,
|
|
self.password_iterations.cast_unsigned(),
|
|
)
|
|
}
|
|
|
|
pub fn check_valid_recovery_code(&self, recovery_code: &str) -> bool {
|
|
if let Some(ref totp_recover) = self.totp_recover {
|
|
crypto::ct_eq(recovery_code, totp_recover.to_lowercase())
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
pub fn check_valid_api_key(&self, key: &str) -> bool {
|
|
matches!(self.api_key, Some(ref api_key) if crypto::ct_eq(api_key, key))
|
|
}
|
|
|
|
/// Set the password hash generated
|
|
/// And resets the security_stamp. Based upon the allow_next_route the security_stamp will be different.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `password` - A str which contains a hashed version of the users master password.
|
|
/// * `new_key` - A String which contains the new aKey value of the users master password.
|
|
/// * `allow_next_route` - A Option<Vec<String>> with the function names of the next allowed (rocket) routes.
|
|
/// These routes are able to use the previous stamp id for the next 2 minutes.
|
|
/// After these 2 minutes this stamp will expire.
|
|
///
|
|
pub async fn set_password(
|
|
&mut self,
|
|
password: &str,
|
|
new_key: Option<String>,
|
|
reset_security_stamp: bool,
|
|
allow_next_route: Option<Vec<String>>,
|
|
conn: &DbConn,
|
|
) -> EmptyResult {
|
|
self.password_hash =
|
|
crypto::hash_password(password.as_bytes(), &self.salt, self.password_iterations.cast_unsigned());
|
|
|
|
if let Some(route) = allow_next_route {
|
|
self.set_stamp_exception(route);
|
|
}
|
|
|
|
if let Some(new_key) = new_key {
|
|
self.akey = new_key;
|
|
}
|
|
|
|
if reset_security_stamp {
|
|
self.reset_security_stamp(conn).await?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn reset_security_stamp(&mut self, conn: &DbConn) -> EmptyResult {
|
|
self.security_stamp = get_uuid();
|
|
Device::rotate_refresh_tokens_by_user(&self.uuid, conn).await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Set the stamp_exception to only allow a subsequent request matching a specific route using the current security-stamp.
|
|
///
|
|
/// # Arguments
|
|
/// * `route_exception` - A Vec<String> with the function names of the next allowed (rocket) routes.
|
|
/// These routes are able to use the previous stamp id for the next 2 minutes.
|
|
/// After these 2 minutes this stamp will expire.
|
|
///
|
|
pub fn set_stamp_exception(&mut self, route_exception: Vec<String>) {
|
|
let stamp_exception = UserStampException {
|
|
routes: route_exception,
|
|
security_stamp: self.security_stamp.clone(),
|
|
expire: (Utc::now() + TimeDelta::try_minutes(2).unwrap()).timestamp(),
|
|
};
|
|
self.stamp_exception = Some(serde_json::to_string(&stamp_exception).unwrap_or_default());
|
|
}
|
|
|
|
/// Resets the stamp_exception to prevent re-use of the previous security-stamp
|
|
pub fn reset_stamp_exception(&mut self) {
|
|
self.stamp_exception = None;
|
|
}
|
|
|
|
pub fn display_name(&self) -> &str {
|
|
// default to email if name is empty
|
|
if self.name.is_empty() {
|
|
&self.email
|
|
} else {
|
|
&self.name
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Database methods
|
|
impl User {
|
|
pub fn verified(&self) -> bool {
|
|
!CONFIG.mail_enabled() || self.verified_at.is_some()
|
|
}
|
|
|
|
/// The `accountKeys` object (upstream's `PrivateKeysResponseModel`), null without a key pair
|
|
pub fn account_keys_json(&self) -> Value {
|
|
if self.private_key.is_some() {
|
|
json!({
|
|
"publicKeyEncryptionKeyPair": {
|
|
"wrappedPrivateKey": self.private_key,
|
|
"publicKey": self.public_key,
|
|
"signedPublicKey": null,
|
|
"object": "publicKeyEncryptionKeyPair",
|
|
},
|
|
"securityState": null,
|
|
"signatureKeyPair": null,
|
|
"object": "privateKeys"
|
|
})
|
|
} else {
|
|
Value::Null
|
|
}
|
|
}
|
|
|
|
pub async fn to_json(&self, conn: &DbConn) -> Value {
|
|
let mut orgs_json = Vec::new();
|
|
for c in Membership::find_confirmed_by_user(&self.uuid, conn).await {
|
|
orgs_json.push(c.to_json(conn).await);
|
|
}
|
|
|
|
let mut orgs_new_json = Vec::new();
|
|
for c in Membership::find_accepted_and_confirmed_by_user(&self.uuid, conn).await {
|
|
orgs_new_json.push(c.to_json(conn).await);
|
|
}
|
|
|
|
let twofactor_enabled = !TwoFactor::find_by_user(&self.uuid, conn).await.is_empty();
|
|
|
|
// TODO: Might want to save the status field in the DB
|
|
let status = if self.password_hash.is_empty() {
|
|
UserStatus::Invited
|
|
} else {
|
|
UserStatus::Enabled
|
|
};
|
|
|
|
let account_keys = self.account_keys_json();
|
|
|
|
json!({
|
|
"_status": status as i32,
|
|
"accountKeys": account_keys,
|
|
"id": self.uuid,
|
|
"name": self.name,
|
|
"email": self.email,
|
|
"emailVerified": self.verified(),
|
|
"premium": true,
|
|
"premiumFromOrganization": false,
|
|
"culture": "en-US",
|
|
"twoFactorEnabled": twofactor_enabled,
|
|
"key": self.akey,
|
|
"privateKey": self.private_key,
|
|
"securityStamp": self.security_stamp,
|
|
"organizations": orgs_json,
|
|
"organizationsNew": orgs_new_json,
|
|
"providers": [],
|
|
"providerOrganizations": [],
|
|
"forcePasswordReset": false,
|
|
"avatarColor": self.avatar_color,
|
|
"usesKeyConnector": false,
|
|
"creationDate": format_date(&self.created_at),
|
|
"object": "profile",
|
|
})
|
|
}
|
|
|
|
pub async fn save(&mut self, conn: &DbConn) -> EmptyResult {
|
|
if !crate::util::is_valid_email(&self.email) {
|
|
err!(format!("User email {} is not a valid email address", self.email.escape_debug()))
|
|
}
|
|
|
|
self.updated_at = Utc::now().naive_utc();
|
|
|
|
db_run! { conn:
|
|
mysql {
|
|
diesel::insert_into(users::table)
|
|
.values(&*self)
|
|
.on_conflict(diesel::dsl::DuplicatedKeys)
|
|
.do_update()
|
|
.set(&*self)
|
|
.execute(conn)
|
|
.map_res("Error saving user")
|
|
}
|
|
postgresql, sqlite {
|
|
diesel::insert_into(users::table) // Insert or update
|
|
.values(&*self)
|
|
.on_conflict(users::uuid)
|
|
.do_update()
|
|
.set(&*self)
|
|
.execute(conn)
|
|
.map_res("Error saving user")
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn delete(self, conn: &DbConn) -> EmptyResult {
|
|
for member in Membership::find_confirmed_by_user(&self.uuid, conn).await {
|
|
if member.atype == MembershipType::Owner
|
|
&& Membership::count_confirmed_by_org_and_type(&member.org_uuid, MembershipType::Owner, conn).await <= 1
|
|
{
|
|
err!("Can't delete last owner")
|
|
}
|
|
}
|
|
|
|
super::Send::delete_all_by_user(&self.uuid, conn).await?;
|
|
EmergencyAccess::delete_all_by_user(&self.uuid, conn).await?;
|
|
EmergencyAccess::delete_all_by_grantee_email(&self.email, conn).await?;
|
|
Membership::delete_all_by_user(&self.uuid, conn).await?;
|
|
Cipher::delete_all_by_user(&self.uuid, conn).await?;
|
|
Favorite::delete_all_by_user(&self.uuid, conn).await?;
|
|
Folder::delete_all_by_user(&self.uuid, conn).await?;
|
|
Device::delete_all_by_user(&self.uuid, conn).await?;
|
|
TwoFactor::delete_all_by_user(&self.uuid, conn).await?;
|
|
TwoFactorIncomplete::delete_all_by_user(&self.uuid, conn).await?;
|
|
Invitation::take(&self.email, conn).await; // Delete invitation if any
|
|
|
|
conn.run(move |conn| {
|
|
diesel::delete(users::table.filter(users::uuid.eq(self.uuid))).execute(conn).map_res("Error deleting user")
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub async fn update_uuid_revision(uuid: &UserId, conn: &DbConn) {
|
|
if let Err(e) = Self::update_revision_impl(uuid, &Utc::now().naive_utc(), conn).await {
|
|
warn!("Failed to update revision for {uuid}: {e:#?}");
|
|
}
|
|
}
|
|
|
|
pub async fn update_all_revisions(conn: &DbConn) -> EmptyResult {
|
|
let updated_at = Utc::now().naive_utc();
|
|
|
|
conn.run(move |conn| {
|
|
retry(|| diesel::update(users::table).set(users::updated_at.eq(updated_at)).execute(conn), 10)
|
|
.map_res("Error updating revision date for all users")
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub async fn update_revision(&mut self, conn: &DbConn) -> EmptyResult {
|
|
self.updated_at = Utc::now().naive_utc();
|
|
|
|
Self::update_revision_impl(&self.uuid, &self.updated_at, conn).await
|
|
}
|
|
|
|
async fn update_revision_impl(uuid: &UserId, date: &NaiveDateTime, conn: &DbConn) -> EmptyResult {
|
|
conn.run(move |conn| {
|
|
retry(
|
|
|| {
|
|
diesel::update(users::table.filter(users::uuid.eq(uuid)))
|
|
.set(users::updated_at.eq(date))
|
|
.execute(conn)
|
|
},
|
|
10,
|
|
)
|
|
.map_res("Error updating user revision")
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub async fn find_by_mail(mail: &str, conn: &DbConn) -> Option<Self> {
|
|
let lower_mail = mail.to_lowercase();
|
|
conn.run(move |conn| users::table.filter(users::email.eq(lower_mail)).first::<Self>(conn).ok()).await
|
|
}
|
|
|
|
pub async fn find_by_uuid(uuid: &UserId, conn: &DbConn) -> Option<Self> {
|
|
conn.run(move |conn| users::table.filter(users::uuid.eq(uuid)).first::<Self>(conn).ok()).await
|
|
}
|
|
|
|
pub async fn find_by_device_for_email2fa(device_uuid: &DeviceId, conn: &DbConn) -> Option<Self> {
|
|
if let Some(user_uuid) = conn
|
|
.run(move |conn| {
|
|
twofactor_incomplete::table
|
|
.filter(twofactor_incomplete::device_uuid.eq(device_uuid))
|
|
.order_by(twofactor_incomplete::login_time.desc())
|
|
.select(twofactor_incomplete::user_uuid)
|
|
.first::<UserId>(conn)
|
|
.ok()
|
|
})
|
|
.await
|
|
{
|
|
return Self::find_by_uuid(&user_uuid, conn).await;
|
|
}
|
|
None
|
|
}
|
|
|
|
pub async fn get_all(conn: &DbConn) -> Vec<(Self, Option<SsoUser>)> {
|
|
conn.run(move |conn| {
|
|
users::table
|
|
.left_join(sso_users::table)
|
|
.select(<(Self, Option<SsoUser>)>::as_select())
|
|
.load(conn)
|
|
.expect("Error loading groups for user")
|
|
.into_iter()
|
|
.collect()
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub async fn last_active(&self, conn: &DbConn) -> Option<NaiveDateTime> {
|
|
match Device::find_latest_active_by_user(&self.uuid, conn).await {
|
|
Some(device) => Some(device.updated_at),
|
|
None => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Invitation {
|
|
pub fn new(email: &str) -> Self {
|
|
let email = email.to_lowercase();
|
|
Self {
|
|
email,
|
|
}
|
|
}
|
|
|
|
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
|
|
if !crate::util::is_valid_email(&self.email) {
|
|
err!(format!("Invitation email {} is not a valid email address", self.email.escape_debug()))
|
|
}
|
|
|
|
db_run! { conn:
|
|
// Not checking for ForeignKey Constraints here
|
|
// Table invitations does not have any ForeignKey Constraints.
|
|
mysql {
|
|
diesel::insert_into(invitations::table)
|
|
.values(self)
|
|
.on_conflict(diesel::dsl::DuplicatedKeys)
|
|
.do_nothing()
|
|
.execute(conn)
|
|
.map_res("Error saving invitation")
|
|
}
|
|
postgresql, sqlite {
|
|
diesel::insert_into(invitations::table)
|
|
.values(self)
|
|
.on_conflict(invitations::email)
|
|
.do_nothing()
|
|
.execute(conn)
|
|
.map_res("Error saving invitation")
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn delete(self, conn: &DbConn) -> EmptyResult {
|
|
conn.run(move |conn| {
|
|
diesel::delete(invitations::table.filter(invitations::email.eq(self.email)))
|
|
.execute(conn)
|
|
.map_res("Error deleting invitation")
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub async fn find_by_mail(mail: &str, conn: &DbConn) -> Option<Self> {
|
|
let lower_mail = mail.to_lowercase();
|
|
conn.run(move |conn| invitations::table.filter(invitations::email.eq(lower_mail)).first::<Self>(conn).ok())
|
|
.await
|
|
}
|
|
|
|
pub async fn take(mail: &str, conn: &DbConn) -> bool {
|
|
match Self::find_by_mail(mail, conn).await {
|
|
Some(invitation) => invitation.delete(conn).await.is_ok(),
|
|
None => false,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(
|
|
Clone,
|
|
Debug,
|
|
DieselNewType,
|
|
FromForm,
|
|
PartialEq,
|
|
Eq,
|
|
Hash,
|
|
Serialize,
|
|
Deserialize,
|
|
AsRef,
|
|
Deref,
|
|
Display,
|
|
From,
|
|
UuidFromParam,
|
|
)]
|
|
#[deref(forward)]
|
|
#[from(forward)]
|
|
pub struct UserId(String);
|
|
|
|
#[derive(
|
|
Clone,
|
|
Debug,
|
|
DieselNewType,
|
|
FromForm,
|
|
PartialEq,
|
|
Eq,
|
|
Hash,
|
|
Serialize,
|
|
Deserialize,
|
|
AsRef,
|
|
Deref,
|
|
Display,
|
|
From,
|
|
UuidFromParam,
|
|
)]
|
|
#[deref(forward)]
|
|
#[from(forward)]
|
|
pub struct KeyId(String);
|
|
|
|
impl SsoUser {
|
|
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
|
|
db_run! { conn:
|
|
mysql {
|
|
diesel::insert_into(sso_users::table)
|
|
.values(self)
|
|
.execute(conn)
|
|
.map_res("Error saving SSO user")
|
|
}
|
|
postgresql, sqlite {
|
|
diesel::insert_into(sso_users::table)
|
|
.values(self)
|
|
.execute(conn)
|
|
.map_res("Error saving SSO user")
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn find_by_identifier(identifier: &str, conn: &DbConn) -> Option<(User, Self)> {
|
|
conn.run(move |conn| {
|
|
users::table
|
|
.inner_join(sso_users::table)
|
|
.select(<(User, Self)>::as_select())
|
|
.filter(sso_users::identifier.eq(identifier))
|
|
.first::<(User, Self)>(conn)
|
|
.ok()
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub async fn find_by_mail(mail: &str, conn: &DbConn) -> Option<(User, Option<Self>)> {
|
|
let lower_mail = mail.to_lowercase();
|
|
|
|
conn.run(move |conn| {
|
|
users::table
|
|
.left_join(sso_users::table)
|
|
.select(<(User, Option<Self>)>::as_select())
|
|
.filter(users::email.eq(lower_mail))
|
|
.first::<(User, Option<Self>)>(conn)
|
|
.ok()
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub async fn delete(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
|
|
conn.run(move |conn| {
|
|
diesel::delete(sso_users::table.filter(sso_users::user_uuid.eq(user_uuid)))
|
|
.execute(conn)
|
|
.map_res("Error deleting sso user")
|
|
})
|
|
.await
|
|
}
|
|
}
|