1use alibi_core::hooks::current_request_hook_context;
3use alibi_core::{
4 AuthContext, AuthInitContext, AuthPlugin, AuthRequest, AuthResponse, AuthResult, AuthRoute,
5 AuthSchema, AuthSession, RequestHookContext, UpdateUser,
6};
7use async_trait::async_trait;
8use std::sync::Arc;
9
10#[derive(Clone, Debug)]
12pub struct LastLoginMethodContext {
13 pub request: RequestHookContext,
14 pub body: Option<alibi_core::utils::json::JsValue>,
16 pub route_path: String,
18 pub params: std::collections::HashMap<String, String>,
19 pub response: Option<AuthResponse>,
22 pub new_session: Option<LastLoginMethodSession>,
24}
25#[derive(Clone, Debug)]
26pub struct LastLoginMethodSession {
27 pub user: alibi_core::wire::UserView,
28 pub session: alibi_core::wire::SessionView,
29}
30pub trait ResolveLastLoginMethod: Send + Sync {
33 fn resolve(&self, context: &LastLoginMethodContext) -> AuthResult<Option<String>>;
34}
35#[async_trait]
37pub trait BeforeStoreLastLoginMethodCookie: Send + Sync {
38 async fn before_store(
39 &self,
40 context: &LastLoginMethodContext,
41 method: &str,
42 ) -> AuthResult<bool>;
43}
44#[derive(Clone)]
45pub struct LastLoginMethodConfig {
46 pub cookie_name: String,
47 pub max_age: f64,
48 pub store_in_database: bool,
49 pub resolver: Option<Arc<dyn ResolveLastLoginMethod>>,
50 pub before_store_cookie: Option<Arc<dyn BeforeStoreLastLoginMethodCookie>>,
51}
52impl std::fmt::Debug for LastLoginMethodConfig {
53 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54 formatter
55 .debug_struct("LastLoginMethodConfig")
56 .field("cookie_name", &self.cookie_name)
57 .field("max_age", &self.max_age)
58 .field("store_in_database", &self.store_in_database)
59 .finish_non_exhaustive()
60 }
61}
62impl Default for LastLoginMethodConfig {
63 fn default() -> Self {
64 Self {
65 cookie_name: "better-auth.last_used_login_method".into(),
66 max_age: 2_592_000.0,
67 store_in_database: false,
68 resolver: None,
69 before_store_cookie: None,
70 }
71 }
72}
73#[derive(Clone, Debug, Default)]
74pub struct LastLoginMethodPlugin {
75 config: LastLoginMethodConfig,
76}
77impl LastLoginMethodPlugin {
78 #[must_use]
79 pub fn new() -> Self {
80 Self::default()
81 }
82 #[must_use]
83 pub const fn with_config(config: LastLoginMethodConfig) -> Self {
84 Self { config }
85 }
86 fn resolve(&self, context: &LastLoginMethodContext) -> AuthResult<Option<String>> {
87 if let Some(resolver) = &self.config.resolver
88 && let Some(method) = resolver.resolve(context).map_err(|error| match error {
89 ordinary @ alibi_core::AuthError::Internal(_) => {
90 alibi_core::AuthError::CallbackFailure(Box::new(ordinary))
91 }
92 other => other,
93 })?
94 {
95 return Ok(Some(method));
96 }
97 let path = &context.route_path;
98 let method = if path.starts_with("/callback/") {
99 context
100 .params
101 .get("id")
102 .map(String::as_str)
103 .or_else(|| path.rsplit('/').next())
104 } else if matches!(path.as_str(), "/sign-in/email" | "/sign-up/email") {
105 Some("email")
106 } else if path.contains("siwe") {
107 Some("siwe")
108 } else if path.contains("/passkey/verify-authentication") {
109 Some("passkey")
110 } else if path.starts_with("/magic-link/verify") {
111 Some("magic-link")
112 } else if path == "/sign-in/email-otp" {
113 Some("email-otp")
114 } else {
115 None
116 };
117 Ok(method.map(str::to_owned))
118 }
119 fn adapter_context<S: AuthSchema>(ctx: &AuthContext<S>) -> Option<LastLoginMethodContext> {
120 let mut request = current_request_hook_context()?;
121 request.path = request
122 .path
123 .strip_prefix(&ctx.config.base_path)
124 .unwrap_or(&request.path)
125 .to_owned();
126 let body = request
127 .extensions
128 .get::<alibi_core::hooks::ValidatedRequestBody>()
129 .map(|body| body.0.clone())
130 .or_else(|| {
131 request
132 .extensions
133 .get::<alibi_core::hooks::TransformedRequestBody>()
134 .map(|body| body.0.clone())
135 })
136 .or_else(|| {
137 request
138 .body
139 .as_deref()
140 .and_then(|body| alibi_core::utils::json::from_slice(body).ok())
141 });
142 let new_session = request
143 .extensions
144 .get::<super::helpers::CompletedSession<S>>()
145 .map(|completed| LastLoginMethodSession {
146 user: completed.callback_user(ctx),
147 session: completed.callback_session(ctx),
148 });
149 let endpoint = request
150 .extensions
151 .get::<alibi_core::plugin::ResolvedEndpoint>();
152 let route_path = endpoint
153 .as_ref()
154 .map_or_else(|| request.path.clone(), |endpoint| endpoint.path.clone());
155 let params = endpoint
156 .map(|endpoint| endpoint.params.clone())
157 .unwrap_or_default();
158 Some(LastLoginMethodContext {
159 body,
160 request,
161 route_path,
162 params,
163 response: None,
164 new_session,
165 })
166 }
167 fn cookie(&self, method: &str, config: &alibi_core::AuthConfig) -> AuthResult<String> {
168 if self.config.max_age > 34_560_000.0 {
169 return Err(alibi_core::AuthError::CallbackFailure(Box::new(
170 alibi_core::AuthError::internal("Invalid login-method cookie lifetime"),
171 )));
172 }
173 let defaults = &config.advanced.default_cookie_attributes;
174 let overrides = config
175 .advanced
176 .cookies
177 .get("session_token")
178 .map(|cookie| &cookie.attributes);
179 let secure = overrides
180 .and_then(|attrs| attrs.secure)
181 .or(defaults.secure)
182 .unwrap_or(config.session.cookie_secure);
183 let same_site = overrides
184 .and_then(|attrs| attrs.same_site.as_ref())
185 .or(defaults.same_site.as_ref())
186 .unwrap_or(&config.session.cookie_same_site);
187 let path = overrides
188 .and_then(|attrs| attrs.path.as_deref())
189 .or(defaults.path.as_deref())
190 .unwrap_or("/");
191 let domain = overrides
192 .and_then(|attrs| attrs.domain.as_deref())
193 .or(defaults.domain.as_deref())
194 .or(config
195 .advanced
196 .cross_sub_domain_cookies
197 .as_ref()
198 .map(|cross| cross.domain.as_str()));
199 let value = urlencoding::encode(method)
200 .replace("%21", "!")
201 .replace("%27", "'")
202 .replace("%28", "(")
203 .replace("%29", ")")
204 .replace("%2A", "*");
205 let mut cookie = cookie::Cookie::build((self.config.cookie_name.clone(), value))
206 .path(path)
207 .secure(secure)
208 .http_only(false)
209 .same_site(match same_site {
210 alibi_core::config::SameSite::Strict => cookie::SameSite::Strict,
211 alibi_core::config::SameSite::Lax => cookie::SameSite::Lax,
212 alibi_core::config::SameSite::None => cookie::SameSite::None,
213 });
214 if let Some(domain) = domain {
215 cookie = cookie.domain(domain.to_owned());
216 }
217 if self.config.max_age >= 0.0 {
218 #[expect(
219 clippy::as_conversions,
220 clippy::cast_possible_truncation,
221 reason = "Cookie max age is finite, bounded, and floors nonnegative JavaScript seconds"
222 )]
223 let seconds = self.config.max_age.floor() as i64;
224 cookie = cookie.max_age(cookie::time::Duration::seconds(seconds));
225 }
226 Ok(cookie.build().to_string())
227 }
228}
229#[async_trait]
230impl<S: AuthSchema> AuthPlugin<S> for LastLoginMethodPlugin {
231 fn static_openapi_metadata(&self) -> alibi_core::PluginOpenApiMetadata {
232 crate::metadata::plugin_metadata(
233 <Self as alibi_core::AuthPlugin<S>>::name(self),
234 &<Self as alibi_core::AuthPlugin<S>>::routes(self),
235 )
236 }
237
238 fn openapi_metadata(
239 &self,
240 ctx: &alibi_core::AuthInitContext<S>,
241 ) -> alibi_core::PluginOpenApiMetadata {
242 crate::metadata::instance_plugin_metadata(
243 <Self as alibi_core::AuthPlugin<S>>::name(self),
244 &<Self as alibi_core::AuthPlugin<S>>::routes(self),
245 ctx,
246 )
247 }
248
249 fn name(&self) -> &'static str {
250 "last-login-method"
251 }
252 fn routes(&self) -> Vec<AuthRoute> {
253 Vec::new()
254 }
255 async fn on_init(&self, ctx: &mut AuthInitContext<S>) -> AuthResult<()> {
256 ctx.set_metadata(
257 "last-login-method.store-in-database",
258 serde_json::json!(self.config.store_in_database),
259 );
260 if !self.config.store_in_database {
261 return Ok(());
262 }
263 ctx.set_metadata("last-login-method.enabled", serde_json::json!(true));
264 let plugin = self.clone();
265 let runtime = Arc::new(AuthContext {
266 config: Arc::clone(&ctx.config),
267 database: Arc::clone(&ctx.database),
268 email_provider: ctx.email_provider.clone(),
269 metadata: ctx.metadata.clone(),
270 extensions: ctx.extensions.clone(),
271 });
272 let runtime_create = Arc::clone(&runtime);
273 ctx.register_user_create_transform(move |mut data| {
274 if let Some(context) = Self::adapter_context(&runtime_create)
275 && let Some(method) = plugin
276 .resolve(&context)?
277 .filter(|method| !method.is_empty())
278 {
279 data.last_login_method = Some(method);
280 }
281 Ok(data)
282 });
283 ctx.register_session_created_hook(Arc::new(LastLoginSessionHook {
284 plugin: self.clone(),
285 context: runtime,
286 }));
287 Ok(())
288 }
289 async fn on_request(
290 &self,
291 _req: &AuthRequest,
292 _ctx: &AuthContext<S>,
293 ) -> AuthResult<Option<AuthResponse>> {
294 Ok(None)
295 }
296 async fn after_request(
297 &self,
298 req: &AuthRequest,
299 ctx: &AuthContext<S>,
300 mut response: AuthResponse,
301 ) -> AuthResult<AuthResponse> {
302 let new_session =
303 super::helpers::completed_response_session(req, ctx, &response).map(|completed| {
304 LastLoginMethodSession {
305 user: completed.callback_user(ctx),
306 session: completed.callback_session(ctx),
307 }
308 });
309 let endpoint = req
310 .extensions()
311 .get::<alibi_core::plugin::ResolvedEndpoint>();
312 let context = LastLoginMethodContext {
313 route_path: endpoint
314 .as_ref()
315 .map_or_else(|| req.path().to_owned(), |endpoint| endpoint.path.clone()),
316 params: endpoint
317 .map(|endpoint| endpoint.params.clone())
318 .unwrap_or_default(),
319 body: req
320 .extensions()
321 .get::<alibi_core::hooks::TransformedRequestBody>()
322 .map(|body| body.0.clone())
323 .or_else(|| {
324 req.body
325 .as_deref()
326 .and_then(|body| alibi_core::utils::json::from_slice(body).ok())
327 }),
328 request: RequestHookContext::from_request(req),
329 response: Some(response.clone()),
330 new_session,
331 };
332 if let Some(method) = self.resolve(&context)?.filter(|method| !method.is_empty())
333 && response
334 .headers
335 .get_all("set-cookie")
336 .any(|header| header.contains(&ctx.config.session.cookie_name))
337 {
338 let permitted = match &self.config.before_store_cookie {
339 None => true,
340 Some(callback) => match callback.before_store(&context, &method).await {
341 Ok(permitted) => permitted,
342 Err(error) => {
343 tracing::error!(%error,"[LastLoginMethod] Error in beforeStoreCookie hook");
344 false
345 }
346 },
347 };
348 if permitted {
349 response
350 .headers
351 .append("set-cookie", self.cookie(&method, &ctx.config)?);
352 }
353 }
354 Ok(response)
355 }
356}
357
358struct LastLoginSessionHook<S: AuthSchema> {
359 plugin: LastLoginMethodPlugin,
360 context: Arc<AuthContext<S>>,
361}
362#[async_trait]
363impl<S: AuthSchema> alibi_core::store::SessionCreatedHook<S> for LastLoginSessionHook<S> {
364 async fn after_create(
365 &self,
366 session: &S::Session,
367 database: &dyn alibi_core::store::AuthStore<S>,
368 ) -> AuthResult<()> {
369 if let Some(context) = LastLoginMethodPlugin::adapter_context(&self.context)
370 && let Some(method) = self
371 .plugin
372 .resolve(&context)?
373 .filter(|method| !method.is_empty())
374 && let Err(error) = database
375 .update_user_record(
376 session.user_id().as_ref(),
377 UpdateUser {
378 last_login_method: Some(Some(method)),
379 ..Default::default()
380 },
381 )
382 .await
383 {
384 tracing::error!(%error,"Failed to update lastLoginMethod");
385 }
386 Ok(())
387 }
388}
389
390pub fn reject_last_login_method_input<S: AuthSchema>(
396 ctx: &AuthContext<S>,
397 value: Option<&alibi_core::utils::json::JsValue>,
398) -> AuthResult<()> {
399 use alibi_core::utils::json::JsValue;
400 let truthy = match value {
401 None | Some(JsValue::Null) => false,
402 Some(JsValue::Bool(value)) => *value,
403 Some(JsValue::Number(value)) => *value != 0.0 && !value.is_nan(),
404 Some(JsValue::String(value)) => !value.is_empty(),
405 Some(JsValue::Array(_) | JsValue::Object(_)) => true,
406 };
407 if ctx
408 .get_metadata("last-login-method.enabled")
409 .and_then(serde_json::Value::as_bool)
410 == Some(true)
411 && truthy
412 {
413 return Err(alibi_core::AuthError::Upstream {
414 status: 400,
415 code: "FIELD_NOT_ALLOWED",
416 message: "lastLoginMethod is not allowed to be set",
417 });
418 }
419 Ok(())
420}