Skip to main content

better_auth_api/plugins/
last_login_method.rs

1//! Track the last login method in a readable cookie and, optionally, the user record.
2
3use std::sync::Arc;
4
5use async_trait::async_trait;
6use better_auth_core::entity::AuthSession;
7use better_auth_core::plugin_runtime::PluginRuntime;
8use better_auth_core::store::database_hooks::{
9    DatabaseHookContext, DatabaseHookControl, DatabaseHooks,
10};
11use better_auth_core::user_fields::{UserConfig, UserFieldConfig};
12use better_auth_core::utils::cookie_utils::{encode_cookie_value, render_cookie};
13use better_auth_core::{
14    AuthContext, AuthInitContext, AuthPlugin, AuthRequest, AuthResponse, AuthResult, AuthRoute,
15    AuthSchema, CreateUser, UpdateUser,
16};
17
18use super::endpoint_context::EndpointContext;
19
20/// Override the detected method. `None` uses the default resolver; an empty string suppresses tracking.
21pub trait LastLoginMethodResolver<S: AuthSchema>: Send + Sync {
22    /// Resolve the method from the active endpoint, runtime, and transaction.
23    fn resolve(&self, context: &EndpointContext<'_, S>) -> AuthResult<Option<String>>;
24}
25
26/// Decide whether the readable method cookie should be written.
27#[async_trait]
28pub trait BeforeStoreLastLoginCookie<S: AuthSchema>: Send + Sync {
29    /// A false result or error suppresses this cookie without failing authentication.
30    async fn before_store_cookie(
31        &self,
32        context: &EndpointContext<'_, S>,
33        method: &str,
34    ) -> AuthResult<bool>;
35}
36
37/// Last-login persistence and cookie options.
38#[derive(Clone)]
39pub struct LastLoginMethodConfig {
40    /// Literal cookie name. This name is not passed through the global cookie prefix resolver.
41    pub cookie_name: String,
42    /// Cookie lifetime in seconds. Zero expires the cookie; a negative value omits Max-Age.
43    pub max_age: f64,
44    /// Persist `lastLoginMethod` on user creation and after session creation commits.
45    pub store_in_database: bool,
46    /// Storage field used by the application's user model.
47    pub field_name: Option<String>,
48}
49
50impl Default for LastLoginMethodConfig {
51    fn default() -> Self {
52        Self {
53            cookie_name: "better-auth.last_used_login_method".into(),
54            max_age: 2_592_000.0,
55            store_in_database: false,
56            field_name: None,
57        }
58    }
59}
60
61/// Last-login plugin with typed endpoint callbacks.
62pub struct LastLoginMethodPlugin<S: AuthSchema> {
63    config: LastLoginMethodConfig,
64    resolver: Option<Arc<dyn LastLoginMethodResolver<S>>>,
65    before_store_cookie: Option<Arc<dyn BeforeStoreLastLoginCookie<S>>>,
66}
67
68impl<S: AuthSchema> Clone for LastLoginMethodPlugin<S> {
69    fn clone(&self) -> Self {
70        Self {
71            config: self.config.clone(),
72            resolver: self.resolver.clone(),
73            before_store_cookie: self.before_store_cookie.clone(),
74        }
75    }
76}
77
78impl<S: AuthSchema> Default for LastLoginMethodPlugin<S> {
79    fn default() -> Self {
80        Self::new(LastLoginMethodConfig::default())
81    }
82}
83
84impl<S: AuthSchema> LastLoginMethodPlugin<S> {
85    /// Construct the plugin with explicit persistence and cookie options.
86    pub fn new(config: LastLoginMethodConfig) -> Self {
87        Self {
88            config,
89            resolver: None,
90            before_store_cookie: None,
91        }
92    }
93
94    /// Configure a synchronous method resolver.
95    pub fn custom_resolve_method(mut self, resolver: Arc<dyn LastLoginMethodResolver<S>>) -> Self {
96        self.resolver = Some(resolver);
97        self
98    }
99
100    /// Configure the asynchronous cookie permission callback.
101    pub fn before_store_cookie(mut self, callback: Arc<dyn BeforeStoreLastLoginCookie<S>>) -> Self {
102        self.before_store_cookie = Some(callback);
103        self
104    }
105
106    fn resolve(&self, context: &EndpointContext<'_, S>) -> AuthResult<Option<String>> {
107        if let Some(resolver) = &self.resolver
108            && let Some(method) = resolver.resolve(context)?
109        {
110            return Ok(Some(method));
111        }
112        let path = context.path.unwrap_or("");
113        let method = if path.starts_with("/callback/") {
114            context
115                .params
116                .get("id")
117                .filter(|value| !value.is_empty())
118                .map(String::as_str)
119                .or_else(|| path.rsplit('/').next())
120        } else if matches!(path, "/sign-in/email" | "/sign-up/email") {
121            Some("email")
122        } else if path.contains("siwe") {
123            Some("siwe")
124        } else if path.contains("/passkey/verify-authentication") {
125            Some("passkey")
126        } else if path.starts_with("/magic-link/verify") {
127            Some("magic-link")
128        } else if path == "/sign-in/email-otp" {
129            Some("email-otp")
130        } else {
131            None
132        };
133        Ok(method.map(str::to_owned))
134    }
135}
136
137struct LoginDatabaseHooks<S: AuthSchema> {
138    plugin: LastLoginMethodPlugin<S>,
139    runtime: PluginRuntime<S>,
140}
141
142fn database_endpoint<'a, S: AuthSchema>(
143    context: &'a DatabaseHookContext<'_, S>,
144    auth: &'a AuthContext<S>,
145) -> Option<EndpointContext<'a, S>> {
146    let request = context.request.as_ref()?;
147    let mut endpoint = EndpointContext::new(Some(&request.request), request.body.clone(), auth);
148    endpoint.path = request.path.as_deref();
149    endpoint.params.clone_from(&request.params);
150    endpoint.transaction = context.transaction;
151    Some(endpoint)
152}
153
154#[better_auth_core::database_hooks("plugin:last-login-method")]
155impl<S: AuthSchema> DatabaseHooks<S> for LoginDatabaseHooks<S> {
156    async fn before_create_user(
157        &self,
158        user: &mut CreateUser,
159        context: &DatabaseHookContext<'_, S>,
160    ) -> AuthResult<DatabaseHookControl> {
161        if context.request.is_none() {
162            return Ok(DatabaseHookControl::Continue);
163        }
164        let auth = self.runtime.context()?;
165        if let Some(endpoint) = database_endpoint(context, &auth)
166            && let Some(method) = self
167                .plugin
168                .resolve(&endpoint)?
169                .filter(|value| !value.is_empty())
170        {
171            let _ = user
172                .additional_fields
173                .insert("lastLoginMethod".into(), method.into());
174        }
175        Ok(DatabaseHookControl::Continue)
176    }
177
178    async fn after_create_session(
179        &self,
180        session: &better_auth_core::wire::SessionView,
181        context: &DatabaseHookContext<'_, S>,
182    ) -> AuthResult<()> {
183        if context.request.is_none() {
184            return Ok(());
185        }
186        let auth = self.runtime.context()?;
187        let Some(endpoint) = database_endpoint(context, &auth) else {
188            return Ok(());
189        };
190        // Resolver errors precede the upstream update catch and must fail the operation.
191        let Some(method) = self
192            .plugin
193            .resolve(&endpoint)?
194            .filter(|value| !value.is_empty())
195        else {
196            return Ok(());
197        };
198        let user_id = session.user_id().into_owned();
199        let Some(user_id) = user_id.as_str().filter(|id| !id.is_empty()) else {
200            return Ok(());
201        };
202        let update = UpdateUser {
203            additional_fields: better_auth_core::FieldMap::from_iter([(
204                "lastLoginMethod".into(),
205                method.into(),
206            )]),
207            ..Default::default()
208        };
209        if let Err(error) = auth.database.update_user(user_id, update).await {
210            // Upstream treats this post-commit metadata update as best effort.
211            better_auth_core::observability::logger::current().error(
212                "Failed to update lastLoginMethod",
213                &[better_auth_core::observability::LogArgument::Error(&error)],
214            );
215        }
216        Ok(())
217    }
218}
219
220#[async_trait]
221impl<S: AuthSchema> AuthPlugin<S> for LastLoginMethodPlugin<S> {
222    fn name(&self) -> &'static str {
223        "last-login-method"
224    }
225    fn routes(&self) -> Vec<AuthRoute> {
226        Vec::new()
227    }
228
229    fn openapi(&self) -> AuthResult<better_auth_core::openapi::OpenApiPluginMetadata> {
230        let metadata = better_auth_core::openapi::OpenApiPluginMetadata::from_routes(
231            <Self as better_auth_core::AuthPlugin<S>>::name(self),
232            <Self as better_auth_core::AuthPlugin<S>>::routes(self),
233        )?;
234        Ok(if self.config.store_in_database {
235            metadata
236        } else {
237            metadata.remove_model("user")
238        })
239    }
240
241    async fn on_init(&self, context: &mut AuthInitContext<S>) -> AuthResult<()> {
242        if self.config.store_in_database {
243            context.register_user_fields(UserConfig {
244                additional_fields: Some(
245                    [(
246                        "lastLoginMethod".into(),
247                        UserFieldConfig {
248                            required: Some(false),
249                            input: Some(false),
250                            field_name: self.config.field_name.clone(),
251                            ..Default::default()
252                        },
253                    )]
254                    .into(),
255                ),
256            });
257            context.register_database_hook(Arc::new(LoginDatabaseHooks {
258                plugin: self.clone(),
259                runtime: context.runtime(),
260            }));
261        }
262        Ok(())
263    }
264
265    async fn on_request(
266        &self,
267        _: &AuthRequest,
268        _: &AuthContext<S>,
269    ) -> AuthResult<Option<AuthResponse>> {
270        Ok(None)
271    }
272
273    async fn after_request(
274        &self,
275        request: &AuthRequest,
276        response: &mut AuthResponse,
277        auth: &AuthContext<S>,
278    ) -> AuthResult<()> {
279        better_auth_core::observability::instrumentation::with_endpoint_hook(
280            &auth.config,
281            request,
282            "after",
283            "plugin:last-login-method",
284            async {
285                let hook_context = better_auth_core::hooks::current_request_hook_context();
286                let body = match &hook_context {
287                    Some(context) => context.body.clone(),
288                    None => request.input_field_value()?,
289                };
290                let mut endpoint = EndpointContext::new(Some(request), body, auth);
291                endpoint.path = Some(hook_context.as_ref().map_or(request.path(), |context| {
292                    context.path.as_deref().unwrap_or(request.path())
293                }));
294                endpoint.response = Some(response);
295                let Some(method) = self.resolve(&endpoint)?.filter(|value| !value.is_empty())
296                else {
297                    return Ok(());
298                };
299                let mut cookie = auth.config.auth_cookie("session_token", Default::default());
300                if !response
301                    .headers
302                    .get_all("set-cookie")
303                    .any(|header| header.contains(&cookie.name))
304                {
305                    return Ok(());
306                }
307                if let Some(callback) = &self.before_store_cookie {
308                    match callback.before_store_cookie(&endpoint, &method).await {
309                        Ok(true) => {}
310                        Ok(false) => return Ok(()),
311                        Err(error) => {
312                            better_auth_core::observability::logger::current().error(
313                                "[LastLoginMethod] Error in beforeStoreCookie hook",
314                                &[better_auth_core::observability::LogArgument::Error(&error)],
315                            );
316                            return Ok(());
317                        }
318                    }
319                }
320                cookie.name = self.config.cookie_name.clone();
321                cookie.attributes.http_only = Some(false);
322                cookie.attributes.max_age = Some(self.config.max_age);
323                response.headers.append(
324                    "Set-Cookie",
325                    render_cookie(&encode_cookie_value(&method), &cookie)?,
326                );
327                Ok(())
328            },
329        )
330        .await
331    }
332}