1use alibi_core::error::codes;
3use alibi_core::hooks::current_request_hook_context;
4use alibi_core::{
5 AuthContext, AuthInitContext, AuthPlugin, AuthRequest, AuthResponse, AuthResult, AuthRoute,
6 AuthSchema, AuthSession, RequestHookContext, UpdateUser,
7};
8use async_trait::async_trait;
9use std::sync::Arc;
10
11#[derive(Clone, Debug)]
13pub struct LastLoginMethodContext {
14 pub request: Option<RequestHookContext>,
16 pub endpoint: Option<alibi_core::endpoint::EndpointCall>,
18 pub endpoint_response: Option<LastLoginMethodEndpointResponse>,
20 pub body: Option<alibi_core::utils::json::JsValue>,
22 pub route_path: String,
24 pub params: std::collections::HashMap<String, String>,
25 pub response: Option<AuthResponse>,
28 pub new_session: Option<LastLoginMethodSession>,
30}
31#[derive(Clone, Debug)]
33pub struct LastLoginMethodEndpointResponse {
34 pub value: Option<alibi_core::utils::json::JsValue>,
35 pub error_body: Option<alibi_core::utils::json::JsValue>,
36 pub headers: alibi_core::Headers,
37 pub status: Option<u16>,
38}
39
40impl LastLoginMethodContext {
41 #[must_use]
43 pub fn headers(&self) -> Option<&std::collections::HashMap<String, String>> {
44 if let Some(call) = &self.endpoint {
45 call.headers()
46 } else {
47 self.request.as_ref().map(|request| &request.headers)
48 }
49 }
50 #[must_use]
51 pub fn header(&self, name: &str) -> Option<&String> {
52 self.headers()?
53 .iter()
54 .find_map(|(key, value)| key.eq_ignore_ascii_case(name).then_some(value))
55 }
56 #[must_use]
57 pub fn method(&self) -> Option<&alibi_core::HttpMethod> {
58 if let Some(call) = &self.endpoint {
59 call.method()
60 } else {
61 self.request.as_ref().map(|request| &request.method)
62 }
63 }
64}
65
66#[derive(Clone, Debug)]
67pub struct LastLoginMethodSession {
68 pub user: alibi_core::wire::UserView,
69 pub session: alibi_core::wire::SessionView,
70}
71pub trait ResolveLastLoginMethod: Send + Sync {
74 fn resolve(&self, context: &LastLoginMethodContext) -> AuthResult<Option<String>>;
75}
76#[async_trait]
78pub trait BeforeStoreLastLoginMethodCookie: Send + Sync {
79 async fn before_store(
80 &self,
81 context: &LastLoginMethodContext,
82 method: &str,
83 ) -> AuthResult<bool>;
84}
85#[derive(Clone)]
86pub struct LastLoginMethodConfig {
87 pub cookie_name: String,
88 pub max_age: f64,
89 pub store_in_database: bool,
90 pub resolver: Option<Arc<dyn ResolveLastLoginMethod>>,
91 pub before_store_cookie: Option<Arc<dyn BeforeStoreLastLoginMethodCookie>>,
92}
93impl std::fmt::Debug for LastLoginMethodConfig {
94 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95 formatter
96 .debug_struct("LastLoginMethodConfig")
97 .field("cookie_name", &self.cookie_name)
98 .field("max_age", &self.max_age)
99 .field("store_in_database", &self.store_in_database)
100 .finish_non_exhaustive()
101 }
102}
103impl Default for LastLoginMethodConfig {
104 fn default() -> Self {
105 Self {
106 cookie_name: "better-auth.last_used_login_method".into(),
107 max_age: 2_592_000.0,
108 store_in_database: false,
109 resolver: None,
110 before_store_cookie: None,
111 }
112 }
113}
114#[derive(Clone, Debug, Default)]
115pub struct LastLoginMethodPlugin {
116 config: LastLoginMethodConfig,
117}
118impl LastLoginMethodPlugin {
119 #[must_use]
120 pub fn new() -> Self {
121 Self::default()
122 }
123 #[must_use]
124 pub const fn with_config(config: LastLoginMethodConfig) -> Self {
125 Self { config }
126 }
127 fn resolve(&self, context: &LastLoginMethodContext) -> AuthResult<Option<String>> {
128 if let Some(resolver) = &self.config.resolver
129 && let Some(method) = resolver.resolve(context).map_err(|error| match error {
130 ordinary @ alibi_core::AuthError::Internal(_) => {
131 alibi_core::AuthError::CallbackFailure(Box::new(ordinary))
132 }
133 other => other,
134 })?
135 {
136 return Ok(Some(method));
137 }
138 let path = &context.route_path;
139 let method = if path.starts_with("/callback/") {
140 context
141 .params
142 .get("id")
143 .map(String::as_str)
144 .or_else(|| path.rsplit('/').next())
145 } else if matches!(path.as_str(), "/sign-in/email" | "/sign-up/email") {
146 Some("email")
147 } else if path.contains("siwe") {
148 Some("siwe")
149 } else if path.contains("/passkey/verify-authentication") {
150 Some("passkey")
151 } else if path.starts_with("/magic-link/verify") {
152 Some("magic-link")
153 } else if path == "/sign-in/email-otp" {
154 Some("email-otp")
155 } else {
156 None
157 };
158 Ok(method.map(str::to_owned))
159 }
160 fn adapter_context<S: AuthSchema>(ctx: &AuthContext<S>) -> Option<LastLoginMethodContext> {
161 if let Some(call) = alibi_core::endpoint::current_endpoint_call_context() {
162 let mut context = Self::endpoint_context(&call);
163 let http_request = current_request_hook_context().filter(|request| {
167 request
168 .extensions
169 .get::<alibi_core::plugin::ResolvedEndpoint>()
170 .is_some()
171 });
172 let extensions = http_request
173 .as_ref()
174 .map_or_else(|| call.extensions(), |request| &request.extensions);
175 context.body = extensions
176 .get::<alibi_core::hooks::ValidatedRequestBody>()
177 .map(|body| body.0.clone())
178 .or_else(|| {
179 extensions
180 .get::<alibi_core::hooks::TransformedRequestBody>()
181 .map(|body| body.0.clone())
182 })
183 .or(context.body);
184 if let Some(mut request) = http_request {
185 if let Some(endpoint) = request
186 .extensions
187 .get::<alibi_core::plugin::ResolvedEndpoint>()
188 {
189 context.params.clone_from(&endpoint.params);
190 }
191 if request.path.starts_with(&ctx.config.base_path) {
192 request.path.replace_range(..ctx.config.base_path.len(), "");
193 }
194 context.request = Some(request);
195 }
196 return Some(context);
197 }
198 let mut request = current_request_hook_context()?;
199 request.path = request
200 .path
201 .strip_prefix(&ctx.config.base_path)
202 .unwrap_or(&request.path)
203 .to_owned();
204 let body = request
205 .extensions
206 .get::<alibi_core::hooks::ValidatedRequestBody>()
207 .map(|body| body.0.clone())
208 .or_else(|| {
209 request
210 .extensions
211 .get::<alibi_core::hooks::TransformedRequestBody>()
212 .map(|body| body.0.clone())
213 })
214 .or_else(|| {
215 request
216 .body
217 .as_deref()
218 .and_then(|body| alibi_core::utils::json::from_slice(body).ok())
219 });
220 let new_session = request
221 .extensions
222 .get::<super::helpers::CompletedSession<S>>()
223 .map(|completed| LastLoginMethodSession {
224 user: completed.callback_user(ctx),
225 session: completed.callback_session(ctx),
226 });
227 let endpoint = request
228 .extensions
229 .get::<alibi_core::plugin::ResolvedEndpoint>();
230 let route_path = endpoint
231 .as_ref()
232 .map_or_else(|| request.path.clone(), |endpoint| endpoint.path.clone());
233 let params = endpoint
234 .map(|endpoint| endpoint.params.clone())
235 .unwrap_or_default();
236 Some(LastLoginMethodContext {
237 body,
238 request: Some(request),
239 endpoint: None,
240 endpoint_response: None,
241 route_path,
242 params,
243 response: None,
244 new_session,
245 })
246 }
247 fn endpoint_context(call: &alibi_core::endpoint::EndpointCall) -> LastLoginMethodContext {
248 let new_session = alibi_core::session::cookie_cache::runtime::published_session(call)
249 .map(|(user, session)| LastLoginMethodSession { user, session });
250 let params = call
251 .params()
252 .and_then(|value| value.as_object())
253 .map(|values| {
254 values
255 .iter()
256 .filter_map(|(key, value)| {
257 value.as_str().map(|value| (key.clone(), value.to_owned()))
258 })
259 .collect()
260 })
261 .unwrap_or_default();
262 LastLoginMethodContext {
263 request: call.request().map(RequestHookContext::from_request),
264 endpoint: Some(call.clone()),
265 endpoint_response: None,
266 body: call.body().cloned(),
267 route_path: call.path().unwrap_or_default().to_owned(),
268 params,
269 response: None,
270 new_session,
271 }
272 }
273 fn cookie(&self, method: &str, config: &alibi_core::AuthConfig) -> AuthResult<String> {
274 if self.config.max_age > 34_560_000.0 {
275 return Err(alibi_core::AuthError::CallbackFailure(Box::new(
276 alibi_core::AuthError::internal("Invalid login-method cookie lifetime"),
277 )));
278 }
279 let defaults = &config.advanced.default_cookie_attributes;
280 let overrides = config
281 .advanced
282 .cookies
283 .get("session_token")
284 .map(|cookie| &cookie.attributes);
285 let secure = overrides
286 .and_then(|attrs| attrs.secure)
287 .or(defaults.secure)
288 .unwrap_or(config.session.cookie_secure);
289 let same_site = overrides
290 .and_then(|attrs| attrs.same_site.as_ref())
291 .or(defaults.same_site.as_ref())
292 .unwrap_or(&config.session.cookie_same_site);
293 let path = overrides
294 .and_then(|attrs| attrs.path.as_deref())
295 .or(defaults.path.as_deref())
296 .unwrap_or("/");
297 let domain = overrides
298 .and_then(|attrs| attrs.domain.as_deref())
299 .or(defaults.domain.as_deref())
300 .or_else(|| {
301 config
302 .advanced
303 .cross_sub_domain_cookies
304 .as_ref()
305 .map(|cross| cross.domain.as_str())
306 });
307 let value = urlencoding::encode(method)
308 .replace("%21", "!")
309 .replace("%27", "'")
310 .replace("%28", "(")
311 .replace("%29", ")")
312 .replace("%2A", "*");
313 let mut cookie = cookie::Cookie::build((self.config.cookie_name.clone(), value))
314 .path(path)
315 .secure(secure)
316 .http_only(false)
317 .same_site(match same_site {
318 alibi_core::config::SameSite::Strict => cookie::SameSite::Strict,
319 alibi_core::config::SameSite::Lax => cookie::SameSite::Lax,
320 alibi_core::config::SameSite::None => cookie::SameSite::None,
321 });
322 if let Some(domain) = domain {
323 cookie = cookie.domain(domain.to_owned());
324 }
325 if self.config.max_age >= 0.0 {
326 #[expect(
327 clippy::as_conversions,
328 clippy::cast_possible_truncation,
329 reason = "Cookie max age is finite, bounded, and floors nonnegative JavaScript seconds"
330 )]
331 let seconds = self.config.max_age.floor() as i64;
332 cookie = cookie.max_age(cookie::time::Duration::seconds(seconds));
333 }
334 Ok(cookie.build().to_string())
335 }
336}
337#[async_trait]
338impl<S: AuthSchema> AuthPlugin<S> for LastLoginMethodPlugin {
339 route_openapi_metadata!(S);
340
341 fn name(&self) -> &'static str {
342 "last-login-method"
343 }
344 fn routes(&self) -> Vec<AuthRoute> {
345 Vec::new()
346 }
347 fn endpoint_hooks(&self) -> Vec<&dyn alibi_core::endpoint::EndpointHook<S>> {
348 vec![self]
349 }
350
351 async fn on_init(&self, ctx: &mut AuthInitContext<S>) -> AuthResult<()> {
352 ctx.set_metadata(
353 "last-login-method.store-in-database",
354 serde_json::json!(self.config.store_in_database),
355 );
356 if !self.config.store_in_database {
357 return Ok(());
358 }
359 ctx.set_metadata("last-login-method.enabled", serde_json::json!(true));
360 let plugin = self.clone();
361 let runtime = Arc::new(AuthContext {
362 config: Arc::clone(&ctx.config),
363 database: Arc::clone(&ctx.database),
364 email_provider: ctx.email_provider.clone(),
365 metadata: ctx.metadata.clone(),
366 extensions: ctx.extensions.clone(),
367 });
368 let runtime_create = Arc::clone(&runtime);
369 ctx.register_user_create_transform(move |mut data| {
370 if let Some(context) = Self::adapter_context(&runtime_create)
371 && let Some(method) = plugin
372 .resolve(&context)?
373 .filter(|method| !method.is_empty())
374 {
375 data.last_login_method = Some(method);
376 }
377 Ok(data)
378 });
379 ctx.register_session_created_hook(Arc::new(LastLoginSessionHook {
380 plugin: self.clone(),
381 context: runtime,
382 }));
383 Ok(())
384 }
385 async fn on_request(
386 &self,
387 _req: &AuthRequest,
388 _ctx: &AuthContext<S>,
389 ) -> AuthResult<Option<AuthResponse>> {
390 Ok(None)
391 }
392 async fn after_request(
393 &self,
394 req: &AuthRequest,
395 ctx: &AuthContext<S>,
396 mut response: AuthResponse,
397 ) -> AuthResult<AuthResponse> {
398 let new_session = super::helpers::completed_response_session(req, ctx, &response)
399 .map(|completed| LastLoginMethodSession {
400 user: completed.callback_user(ctx),
401 session: completed.callback_session(ctx),
402 })
403 .or_else(|| {
404 alibi_core::session::cookie_cache::runtime::published_session(req)
405 .map(|(user, session)| LastLoginMethodSession { user, session })
406 });
407 let mut request =
408 current_request_hook_context().unwrap_or_else(|| RequestHookContext::from_request(req));
409 request.path = request
410 .path
411 .strip_prefix(&ctx.config.base_path)
412 .unwrap_or(&request.path)
413 .to_owned();
414 request.headers.clone_from(&req.headers);
416 let endpoint = req
417 .extensions()
418 .get::<alibi_core::plugin::ResolvedEndpoint>();
419 let context = LastLoginMethodContext {
420 route_path: endpoint
421 .as_ref()
422 .map_or_else(|| req.path().to_owned(), |endpoint| endpoint.path.clone()),
423 params: endpoint
424 .map(|endpoint| endpoint.params.clone())
425 .unwrap_or_default(),
426 body: req
427 .extensions()
428 .get::<alibi_core::hooks::TransformedRequestBody>()
429 .map(|body| body.0.clone())
430 .or_else(|| {
431 req.extensions()
432 .get::<alibi_core::types::ParsedRequestBody>()
433 .and_then(|body| match body.as_ref() {
434 alibi_core::types::ParsedRequestBody::Value(body) => Some(body.clone()),
435 alibi_core::types::ParsedRequestBody::Opaque(_) => None,
436 })
437 })
438 .or_else(|| {
439 req.body
440 .as_deref()
441 .and_then(|body| alibi_core::utils::json::from_slice(body).ok())
442 }),
443 request: Some(request),
444 endpoint: None,
445 endpoint_response: None,
446 response: Some(response.clone()),
447 new_session,
448 };
449 if let Some(method) = self.resolve(&context)?.filter(|method| !method.is_empty())
450 && response
451 .headers
452 .get_all("set-cookie")
453 .any(|header| header.contains(&ctx.config.session.cookie_name))
454 {
455 let permitted = match &self.config.before_store_cookie {
456 None => true,
457 Some(callback) => match callback.before_store(&context, &method).await {
458 Ok(permitted) => permitted,
459 Err(error) => {
460 tracing::error!(%error,"[LastLoginMethod] Error in beforeStoreCookie hook");
461 false
462 }
463 },
464 };
465 if permitted {
466 response
467 .headers
468 .append("set-cookie", self.cookie(&method, &ctx.config)?);
469 }
470 }
471 Ok(response)
472 }
473}
474
475#[async_trait]
476impl<S: AuthSchema> alibi_core::endpoint::EndpointHook<S> for LastLoginMethodPlugin {
477 async fn after(
478 &self,
479 call: &alibi_core::endpoint::EndpointCall,
480 ctx: &AuthContext<S>,
481 mut response: alibi_core::endpoint::EndpointResponse,
482 ) -> AuthResult<alibi_core::endpoint::EndpointResponse> {
483 let mut context = Self::endpoint_context(call);
484 context.endpoint_response = Some(LastLoginMethodEndpointResponse {
485 value: response.result().as_ref().ok().cloned(),
486 error_body: response.error_body().cloned(),
487 headers: response.headers().clone(),
488 status: response.status(),
489 });
490 if let Some(method) = self.resolve(&context)?.filter(|method| !method.is_empty())
491 && response
492 .headers()
493 .get_all("set-cookie")
494 .any(|header| header.contains(&ctx.config.session.cookie_name))
495 {
496 let permitted = match &self.config.before_store_cookie {
497 None => true,
498 Some(callback) => match callback.before_store(&context, &method).await {
499 Ok(permitted) => permitted,
500 Err(error) => {
501 tracing::error!(%error,"[LastLoginMethod] Error in beforeStoreCookie hook");
502 false
503 }
504 },
505 };
506 if permitted {
507 response = response.with_header("set-cookie", self.cookie(&method, &ctx.config)?);
508 }
509 }
510 Ok(response)
511 }
512}
513
514struct LastLoginSessionHook<S: AuthSchema> {
515 plugin: LastLoginMethodPlugin,
516 context: Arc<AuthContext<S>>,
517}
518#[async_trait]
519impl<S: AuthSchema> alibi_core::store::SessionCreatedHook<S> for LastLoginSessionHook<S> {
520 async fn after_create(
521 &self,
522 session: &S::Session,
523 database: &dyn alibi_core::store::AuthStore<S>,
524 ) -> AuthResult<()> {
525 if let Some(context) = LastLoginMethodPlugin::adapter_context(&self.context)
526 && let Some(method) = self
527 .plugin
528 .resolve(&context)?
529 .filter(|method| !method.is_empty())
530 && let Err(error) = database
531 .update_user_record(
532 session.user_id().as_ref(),
533 UpdateUser {
534 last_login_method: Some(Some(method)),
535 ..Default::default()
536 },
537 )
538 .await
539 {
540 tracing::error!(%error,"Failed to update lastLoginMethod");
541 }
542 Ok(())
543 }
544}
545
546pub fn reject_last_login_method_input<S: AuthSchema>(
552 ctx: &AuthContext<S>,
553 value: Option<&alibi_core::utils::json::JsValue>,
554) -> AuthResult<()> {
555 use alibi_core::utils::json::JsValue;
556 let truthy = match value {
557 None | Some(JsValue::Null) => false,
558 Some(JsValue::Bool(value)) => *value,
559 Some(JsValue::Number(value)) => *value != 0.0 && !value.is_nan(),
560 Some(JsValue::String(value)) => !value.is_empty(),
561 Some(JsValue::Date(_) | JsValue::Array(_) | JsValue::Object(_)) => true,
562 };
563 if ctx
564 .get_metadata("last-login-method.enabled")
565 .and_then(serde_json::Value::as_bool)
566 == Some(true)
567 && truthy
568 {
569 return Err(alibi_core::AuthError::Upstream {
570 status: 400,
571 code: codes::FIELD_NOT_ALLOWED,
572 message: "lastLoginMethod is not allowed to be set",
573 });
574 }
575 Ok(())
576}