Skip to main content

better_auth_api/plugins/
user_admission.rs

1//! Application admission policy for user provisioning and provider authentication.
2
3use super::endpoint_context::EndpointContext;
4use async_trait::async_trait;
5use better_auth_core::{AuthError, AuthResponse, AuthResult, AuthSchema, CreateUser};
6use better_auth_core::{FieldMap, FieldValue};
7use serde::Serialize;
8use serde_json::Value;
9use std::sync::Arc;
10
11/// The operation that is subject to the admission policy.
12#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
13#[serde(rename_all = "kebab-case")]
14pub enum UserValidationAction {
15    CreateUser,
16    LinkAccount,
17    SignIn,
18}
19
20/// Provider identity and the original, unmapped profile.
21#[derive(Debug, Clone, Serialize)]
22#[serde(rename_all = "camelCase")]
23pub struct UserValidationProvider {
24    pub provider_id: String,
25    /// `None` preserves an absent profile; `Some(Value::Null)` preserves explicit null.
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub profile: Option<Value>,
28}
29
30/// Provenance of a user validation operation.
31#[derive(Debug, Clone, Serialize)]
32pub struct UserValidationSource {
33    pub action: UserValidationAction,
34    pub method: String,
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub oauth: Option<UserValidationProvider>,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub sso: Option<UserValidationProvider>,
39}
40impl UserValidationSource {
41    pub fn new(method: impl Into<String>, action: UserValidationAction) -> Self {
42        Self {
43            action,
44            method: method.into(),
45            oauth: None,
46            sso: None,
47        }
48    }
49    pub(crate) fn oauth(
50        provider: &str,
51        profile: Option<&Value>,
52        action: UserValidationAction,
53    ) -> Self {
54        Self {
55            oauth: Some(UserValidationProvider {
56                provider_id: provider.to_owned(),
57                profile: profile.cloned(),
58            }),
59            ..Self::new("oauth", action)
60        }
61    }
62}
63
64/// User input before database hooks and adapter field transforms.
65#[derive(Debug, Clone, Serialize)]
66pub struct UserValidationData {
67    /// Public field names. Generated identifiers are absent during creation.
68    #[serde(with = "better_auth_core::field_value::serde::map")]
69    pub user: FieldMap,
70    pub source: UserValidationSource,
71}
72
73/// A public rejection returned by the application's admission policy.
74#[derive(Debug, Clone)]
75pub struct UserValidationRejection {
76    pub error: String,
77    pub error_description: Option<String>,
78}
79impl UserValidationRejection {
80    pub fn new(error: impl Into<String>) -> Self {
81        Self {
82            error: error.into(),
83            error_description: None,
84        }
85    }
86    pub fn with_description(mut self, description: impl Into<String>) -> Self {
87        self.error_description = Some(description.into());
88        self
89    }
90    pub(crate) fn message(&self) -> &str {
91        self.error_description
92            .as_deref()
93            .filter(|value| !value.is_empty())
94            .unwrap_or(&self.error)
95    }
96    pub(crate) fn into_auth_error(self) -> AuthError {
97        match AuthResponse::json(
98            403,
99            &serde_json::json!({"code": self.error, "message": self.message()}),
100        ) {
101            Ok(response) => response.into(),
102            Err(error) => error.into(),
103        }
104    }
105}
106
107/// Equivalent to `user.validateUserInfo`. Returning `None` admits the operation.
108///
109/// Callback errors reject with `validation_failed`; they never permit authentication.
110#[async_trait]
111pub trait ValidateUserInfo<S: AuthSchema>: Send + Sync {
112    async fn validate(
113        &self,
114        data: &UserValidationData,
115        context: &EndpointContext<'_, S>,
116    ) -> AuthResult<Option<UserValidationRejection>>;
117}
118
119pub(crate) async fn validate<S: AuthSchema>(
120    data: UserValidationData,
121    endpoint: &EndpointContext<'_, S>,
122) -> Result<(), UserValidationRejection> {
123    let Some(callback) = endpoint
124        .auth
125        .extensions
126        .get::<Arc<dyn ValidateUserInfo<S>>>()
127    else {
128        return Ok(());
129    };
130    let invalid_source = if data.source.method.is_empty() {
131        Some("User validation source is required")
132    } else if data.source.method == "oauth"
133        && data
134            .source
135            .oauth
136            .as_ref()
137            .is_none_or(|provider| provider.provider_id.is_empty())
138    {
139        Some("OAuth user validation source requires oauth.providerId")
140    } else if matches!(data.source.method.as_str(), "sso-oidc" | "sso-saml")
141        && data
142            .source
143            .sso
144            .as_ref()
145            .is_none_or(|provider| provider.provider_id.is_empty())
146    {
147        Some("SSO user validation source requires sso.providerId")
148    } else {
149        None
150    };
151    if let Some(message) = invalid_source {
152        return Err(
153            UserValidationRejection::new("validation_source_missing").with_description(message)
154        );
155    }
156    match callback.validate(&data, endpoint).await {
157        Ok(Some(rejection)) if !rejection.error.is_empty() => Err(rejection),
158        Ok(_) => Ok(()),
159        Err(error) => {
160            // Upstream deliberately masks application exceptions at this fail-closed boundary.
161            better_auth_core::observability::logger::current().error(
162                "User validation callback failed",
163                &[better_auth_core::observability::LogArgument::Error(&error)],
164            );
165            Err(UserValidationRejection::new("validation_failed")
166                .with_description("User validation failed"))
167        }
168    }
169}
170
171pub(crate) async fn validate_create<S: AuthSchema>(
172    input: &CreateUser,
173    source: UserValidationSource,
174    endpoint: &EndpointContext<'_, S>,
175) -> Result<(), UserValidationRejection> {
176    if endpoint
177        .auth
178        .extensions
179        .get::<Arc<dyn ValidateUserInfo<S>>>()
180        .is_none()
181    {
182        return Ok(());
183    }
184    let mut user = input.additional_fields.clone();
185    for (name, field) in [("name", &input.name), ("image", &input.image)] {
186        let value = field.field_value();
187        if value.is_undefined() {
188            continue;
189        }
190        let _ = user.insert(name.into(), value);
191    }
192    for (name, value) in [
193        ("id", input.id.as_ref()),
194        ("email", input.email.as_ref()),
195        ("phoneNumber", input.phone_number.as_ref()),
196        ("role", input.role.as_ref()),
197    ] {
198        if let Some(value) = value {
199            let _ = user.insert(
200                name.into(),
201                FieldValue::String(if name == "email" {
202                    value.to_lowercase()
203                } else {
204                    value.clone()
205                }),
206            );
207        }
208    }
209    for (name, value) in [
210        ("username", &input.username),
211        ("displayUsername", &input.display_username),
212    ] {
213        if let Some(value) = value {
214            let _ = user.insert(
215                name.into(),
216                value
217                    .clone()
218                    .map(FieldValue::String)
219                    .unwrap_or(FieldValue::Null),
220            );
221        }
222    }
223    for (name, value) in [
224        ("banned", input.banned),
225        ("emailVerified", input.email_verified),
226        ("isAnonymous", input.is_anonymous),
227        ("phoneNumberVerified", input.phone_number_verified),
228    ] {
229        if let Some(value) = value {
230            let _ = user.insert(name.into(), FieldValue::Bool(value));
231        }
232    }
233    if let Some(value) = &input.ban_reason {
234        let _ = user.insert("banReason".into(), value.clone().into());
235    }
236    if let Some(value) = &input.ban_expires {
237        let _ = user.insert("banExpires".into(), FieldValue::Date(value.clone()));
238    }
239    if let Some(value) = &input.metadata {
240        let _ = user.insert("metadata".into(), value.clone());
241    }
242    let now = chrono::Utc::now();
243    let _ = user.entry("createdAt".into()).or_insert(FieldValue::Date(
244        input.created_at.clone().unwrap_or_else(|| now.into()),
245    ));
246    let _ = user.entry("updatedAt".into()).or_insert(FieldValue::Date(
247        input.updated_at.clone().unwrap_or_else(|| now.into()),
248    ));
249    validate(UserValidationData { user, source }, endpoint).await
250}
251
252pub(crate) async fn create_user<S: AuthSchema>(
253    mut input: CreateUser,
254    method: &str,
255    endpoint: &EndpointContext<'_, S>,
256) -> AuthResult<better_auth_core::wire::UserView> {
257    validate_create(
258        &input,
259        UserValidationSource::new(method, UserValidationAction::CreateUser),
260        endpoint,
261    )
262    .await
263    .map_err(UserValidationRejection::into_auth_error)?;
264    super::helpers::apply_default_role(endpoint.auth, &mut input);
265    endpoint.auth.database.create_user(input).await
266}