1use 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
20pub trait LastLoginMethodResolver<S: AuthSchema>: Send + Sync {
22 fn resolve(&self, context: &EndpointContext<'_, S>) -> AuthResult<Option<String>>;
24}
25
26#[async_trait]
28pub trait BeforeStoreLastLoginCookie<S: AuthSchema>: Send + Sync {
29 async fn before_store_cookie(
31 &self,
32 context: &EndpointContext<'_, S>,
33 method: &str,
34 ) -> AuthResult<bool>;
35}
36
37#[derive(Clone)]
39pub struct LastLoginMethodConfig {
40 pub cookie_name: String,
42 pub max_age: f64,
44 pub store_in_database: bool,
46 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
61pub 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 pub fn new(config: LastLoginMethodConfig) -> Self {
87 Self {
88 config,
89 resolver: None,
90 before_store_cookie: None,
91 }
92 }
93
94 pub fn custom_resolve_method(mut self, resolver: Arc<dyn LastLoginMethodResolver<S>>) -> Self {
96 self.resolver = Some(resolver);
97 self
98 }
99
100 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 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 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}