Skip to main content

better_auth_api/plugins/password_management/
mod.rs

1use async_trait::async_trait;
2use std::sync::Arc;
3
4use better_auth_core::AuthSession;
5use better_auth_core::{AuthContext, AuthPlugin, AuthRoute};
6use better_auth_core::{AuthError, AuthResult};
7use better_auth_core::{AuthRequest, AuthResponse, HttpMethod};
8
9use better_auth_core::RequestMeta;
10use better_auth_core::utils::password::PasswordHasher;
11
12use super::StatusResponse;
13
14mod callbacks;
15pub use callbacks::{PasswordManagementCallbacks, PasswordResetEmail};
16pub(super) mod handlers;
17mod request;
18pub(super) mod types;
19
20#[cfg(test)]
21mod duration_tests;
22#[cfg(test)]
23mod tests;
24
25use handlers::*;
26use types::*;
27
28/// Type alias for the async password-reset callback to keep Clippy happy.
29pub use better_auth_core::utils::password::{OnPasswordResetCallback, PasswordResetEvent};
30
31/// Trait for sending password reset emails.
32///
33/// This callback powers `POST /request-password-reset` and is required to
34/// enable that route. The user is provided as a serialized `serde_json::Value`
35/// since `AuthUser` is not object-safe.
36#[async_trait]
37pub trait SendResetPassword: Send + Sync {
38    /// Send a password reset notification.
39    ///
40    /// * `user` - The user as a serialized JSON value (from `serde_json::to_value`)
41    /// * `url` - The full reset URL including the token
42    /// * `token` - The raw reset token
43    async fn send(&self, user: &serde_json::Value, url: &str, token: &str) -> AuthResult<()>;
44
45    async fn send_with_request(
46        &self,
47        user: &serde_json::Value,
48        url: &str,
49        token: &str,
50        _request: Option<&AuthRequest>,
51    ) -> AuthResult<()> {
52        self.send(user, url, token).await
53    }
54}
55
56/// Password management plugin for password reset and change functionality
57pub struct PasswordManagementPlugin {
58    config: PasswordManagementConfig,
59}
60
61#[derive(Clone, better_auth_core::PluginConfig)]
62#[plugin(name = "PasswordManagementPlugin")]
63pub struct PasswordManagementConfig {
64    /// Reset token lifetime in seconds, including fractions. Zero and NaN select one hour.
65    #[config(default = None)]
66    pub reset_password_token_expires_in: Option<f64>,
67    #[config(default = true)]
68    pub require_current_password: bool,
69    #[config(default = true)]
70    pub send_email_notifications: bool,
71    /// When true, all existing sessions are revoked on password reset (default: false).
72    #[config(default = false)]
73    pub revoke_sessions_on_password_reset: bool,
74    /// Password reset email sender for `POST /request-password-reset`.
75    /// This route is disabled when no sender is configured.
76    #[config(default = None)]
77    pub send_reset_password: Option<Arc<dyn SendResetPassword>>,
78    /// Callback invoked after a password is successfully reset.
79    /// Errors propagate after the password write and before session revocation.
80    #[config(default = None)]
81    pub on_password_reset: Option<Arc<OnPasswordResetCallback>>,
82    /// Custom password hasher. When `None`, the default scrypt hasher is used.
83    #[config(default = None)]
84    pub password_hasher: Option<Arc<dyn PasswordHasher>>,
85}
86
87impl PasswordManagementConfig {
88    /// Read the reset-token lifetime in seconds. Omission, zero and NaN use one hour.
89    pub fn reset_password_token_expires_in(&self) -> f64 {
90        match self.reset_password_token_expires_in {
91            Some(seconds) if seconds != 0.0 && !seconds.is_nan() => seconds,
92            _ => 3600.0,
93        }
94    }
95
96    fn reset_token_expires_at(
97        &self,
98        now: chrono::DateTime<chrono::Utc>,
99    ) -> AuthResult<chrono::DateTime<chrono::Utc>> {
100        better_auth_core::utils::date::from_milliseconds(
101            now.timestamp_millis() as f64 + self.reset_password_token_expires_in() * 1000.0,
102        )
103        .ok_or_else(|| AuthError::config("Reset token expiry is out of range"))
104    }
105}
106
107impl std::fmt::Debug for PasswordManagementConfig {
108    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109        f.debug_struct("PasswordManagementConfig")
110            .field(
111                "reset_password_token_expires_in",
112                &self.reset_password_token_expires_in,
113            )
114            .field("require_current_password", &self.require_current_password)
115            .field("send_email_notifications", &self.send_email_notifications)
116            .field(
117                "revoke_sessions_on_password_reset",
118                &self.revoke_sessions_on_password_reset,
119            )
120            .field(
121                "send_reset_password",
122                &self.send_reset_password.as_ref().map(|_| "custom"),
123            )
124            .field(
125                "on_password_reset",
126                &self.on_password_reset.as_ref().map(|_| "custom"),
127            )
128            .field(
129                "password_hasher",
130                &self.password_hasher.as_ref().map(|_| "custom"),
131            )
132            .finish()
133    }
134}
135
136#[async_trait]
137impl<S: better_auth_core::AuthSchema> AuthPlugin<S> for PasswordManagementPlugin {
138    fn telemetry(&self, options: &mut better_auth_core::observability::telemetry::PluginTelemetry) {
139        let options = &mut options.email_and_password;
140        options.reset_password_token_expires_in = self.config.reset_password_token_expires_in;
141        options.send_reset_password = self.config.send_reset_password.is_some();
142        options.on_password_reset = self.config.on_password_reset.is_some();
143        options.revoke_sessions_on_password_reset = self.config.revoke_sessions_on_password_reset;
144        options.password.hash |= self.config.password_hasher.is_some();
145        options.password.verify |= self.config.password_hasher.is_some();
146    }
147
148    fn password_hasher(&self) -> Option<Arc<dyn PasswordHasher>> {
149        self.config.password_hasher.clone()
150    }
151
152    async fn on_init(&self, ctx: &mut better_auth_core::AuthInitContext<S>) -> AuthResult<()> {
153        ctx.password_policy.on_password_reset = self.config.on_password_reset.clone();
154        ctx.password_policy.revoke_sessions_on_password_reset =
155            self.config.revoke_sessions_on_password_reset;
156        Ok(())
157    }
158
159    fn name(&self) -> &'static str {
160        "password-management"
161    }
162
163    fn telemetry_plugin_id(&self) -> Option<&'static str> {
164        None
165    }
166
167    fn routes(&self) -> Vec<AuthRoute> {
168        vec![
169            AuthRoute::post("/request-password-reset", "requestPasswordReset")
170                .body_validator(request::request_reset_body),
171            AuthRoute::post("/reset-password", "resetPassword")
172                .body_validator(request::reset_body)
173                .query_validator(crate::plugins::query_input::reset_password),
174            AuthRoute::get("/reset-password/{token}", "resetPasswordCallback")
175                .query_validator(crate::plugins::query_input::reset_password_token),
176            AuthRoute::post("/change-password", "changePassword")
177                .body_validator(request::change_body),
178            AuthRoute::post("/verify-password", "verifyPassword")
179                .body_validator(request::verify_body),
180        ]
181    }
182
183    async fn on_request(
184        &self,
185        req: &AuthRequest,
186        ctx: &AuthContext<S>,
187    ) -> AuthResult<Option<AuthResponse>> {
188        match (req.method(), req.path()) {
189            (HttpMethod::Post, "/request-password-reset") => {
190                Ok(Some(self.handle_request_password_reset(req, ctx).await?))
191            }
192            (HttpMethod::Post, "/reset-password") => {
193                Ok(Some(self.handle_reset_password(req, ctx).await?))
194            }
195            (HttpMethod::Post, "/change-password") => {
196                Ok(Some(self.handle_change_password(req, ctx).await?))
197            }
198            (HttpMethod::Post, "/verify-password") => {
199                Ok(Some(self.handle_verify_password(req, ctx).await?))
200            }
201            (HttpMethod::Get, path) if path.starts_with("/reset-password/") => {
202                let token = path.get(16..).unwrap_or(""); // Remove "/reset-password/" prefix
203                Ok(Some(
204                    self.handle_reset_password_token(token, req, ctx).await?,
205                ))
206            }
207            _ => Ok(None),
208        }
209    }
210}
211
212// Implementation methods outside the trait
213impl PasswordManagementPlugin {
214    async fn handle_request_password_reset(
215        &self,
216        req: &AuthRequest,
217        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
218    ) -> AuthResult<AuthResponse> {
219        let body = request::request_reset(req)?;
220        let response = request_password_reset_core(&body, &self.config, req, ctx).await?;
221        Ok(AuthResponse::json(200, &response)?)
222    }
223
224    async fn handle_reset_password(
225        &self,
226        req: &AuthRequest,
227        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
228    ) -> AuthResult<AuthResponse> {
229        let mut body = request::reset(req)?;
230        if body.token.as_deref().is_none_or(str::is_empty) {
231            body.token = req.query_string("token")?.map(str::to_owned);
232        }
233        let response = reset_password_core(&body, req, ctx).await?;
234        Ok(AuthResponse::json(200, &response)?)
235    }
236
237    async fn handle_change_password(
238        &self,
239        req: &AuthRequest,
240        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
241    ) -> AuthResult<AuthResponse> {
242        let body = request::change(req)?;
243
244        // Get current user from session
245        let user = self
246            .get_current_user(req, ctx)
247            .await?
248            .ok_or(AuthError::Unauthenticated)?;
249        let meta = RequestMeta::from_request_with_config(req, &ctx.config.advanced.ip_address);
250
251        let (response, new_token) =
252            change_password_core(&body, &user, &self.config, &meta, ctx).await?;
253
254        let auth_response = AuthResponse::json(200, &response)?;
255
256        // Set session cookie if a new session was created
257        if let Some(data) = new_token {
258            ctx.session_manager()
259                .set_session_cookie(req, data, None)
260                .await?;
261        }
262        Ok(auth_response)
263    }
264
265    async fn handle_verify_password(
266        &self,
267        req: &AuthRequest,
268        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
269    ) -> AuthResult<AuthResponse> {
270        let body = request::verify(req)?;
271
272        let user = self.get_current_user(req, ctx).await?;
273        let Some(user) = user else {
274            // better-call's default body for a session-gated endpoint hit
275            // without a session, which upstream returns verbatim.
276            return Ok(
277                AuthError::AuthenticationFailed("Unauthorized".to_string()).to_auth_response()
278            );
279        };
280        let response = verify_password_core(&body, &user, ctx).await?;
281        Ok(AuthResponse::json(200, &response)?)
282    }
283
284    async fn handle_reset_password_token(
285        &self,
286        token: &str,
287        req: &AuthRequest,
288        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
289    ) -> AuthResult<AuthResponse> {
290        let query = ResetPasswordTokenQuery {
291            callback_url: req.query_string("callbackURL")?.map(str::to_owned),
292        };
293        match reset_password_token_core(token, &query, ctx).await? {
294            ResetPasswordTokenResult::Redirect(url) => {
295                let mut headers = better_auth_core::Headers::new();
296                let _ = headers.insert("Location".to_string(), url);
297                let _ = headers.insert("content-type".to_string(), "application/json".to_string());
298                let mut response = AuthResponse::new(302);
299                response.headers = headers;
300                Ok(response)
301            }
302        }
303    }
304
305    async fn get_current_user<S: better_auth_core::AuthSchema>(
306        &self,
307        req: &AuthRequest,
308        ctx: &AuthContext<S>,
309    ) -> AuthResult<Option<better_auth_core::wire::UserView>> {
310        let session_manager = ctx.session_manager();
311
312        if let Some(token) = session_manager.extract_session_token(req)
313            && let Some(session) = session_manager.get_session(&token).await?
314        {
315            return ctx
316                .database
317                .get_user_by_id(session.user_id().typed()?)
318                .await;
319        }
320
321        Ok(None)
322    }
323}
324
325#[cfg(test)]
326impl PasswordManagementPlugin {
327    fn validate_password(
328        &self,
329        password: &str,
330        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
331    ) -> AuthResult<()> {
332        better_auth_core::utils::password::validate_password(
333            password,
334            ctx.config.password.min_length,
335            ctx.config.password.max_length,
336            ctx,
337        )
338    }
339
340    async fn hash_password(&self, password: &str) -> AuthResult<String> {
341        better_auth_core::utils::password::hash_password(
342            self.config.password_hasher.as_ref(),
343            password,
344        )
345        .await
346    }
347
348    async fn verify_password(&self, password: &str, hash: &str) -> AuthResult<()> {
349        better_auth_core::utils::password::verify_password(
350            self.config.password_hasher.as_ref(),
351            password,
352            hash,
353        )
354        .await
355    }
356}