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 path == "/one-tap/callback" {
146 Some("google")
147 } else if matches!(path.as_str(), "/sign-in/email" | "/sign-up/email") {
148 Some("email")
149 } else if path.contains("siwe") {
150 Some("siwe")
151 } else if path.contains("/passkey/verify-authentication") {
152 Some("passkey")
153 } else if path.starts_with("/magic-link/verify") {
154 Some("magic-link")
155 } else if path == "/sign-in/email-otp" {
156 Some("email-otp")
157 } else {
158 None
159 };
160 Ok(method.map(str::to_owned))
161 }
162 fn adapter_context<S: AuthSchema>(ctx: &AuthContext<S>) -> Option<LastLoginMethodContext> {
163 if let Some(call) = alibi_core::endpoint::current_endpoint_call_context() {
164 let mut context = Self::endpoint_context(&call);
165 let http_request = current_request_hook_context().filter(|request| {
169 request
170 .extensions
171 .get::<alibi_core::plugin::ResolvedEndpoint>()
172 .is_some()
173 });
174 let extensions = http_request
175 .as_ref()
176 .map_or_else(|| call.extensions(), |request| &request.extensions);
177 context.body = extensions
178 .get::<alibi_core::hooks::ValidatedRequestBody>()
179 .map(|body| body.0.clone())
180 .or_else(|| {
181 extensions
182 .get::<alibi_core::hooks::TransformedRequestBody>()
183 .map(|body| body.0.clone())
184 })
185 .or(context.body);
186 if let Some(mut request) = http_request {
187 if let Some(endpoint) = request
188 .extensions
189 .get::<alibi_core::plugin::ResolvedEndpoint>()
190 {
191 context.params.clone_from(&endpoint.params);
192 }
193 if request.path.starts_with(&ctx.config.base_path) {
194 request.path.replace_range(..ctx.config.base_path.len(), "");
195 }
196 context.request = Some(request);
197 }
198 return Some(context);
199 }
200 let mut request = current_request_hook_context()?;
201 request.path = request
202 .path
203 .strip_prefix(&ctx.config.base_path)
204 .unwrap_or(&request.path)
205 .to_owned();
206 let body = request
207 .extensions
208 .get::<alibi_core::hooks::ValidatedRequestBody>()
209 .map(|body| body.0.clone())
210 .or_else(|| {
211 request
212 .extensions
213 .get::<alibi_core::hooks::TransformedRequestBody>()
214 .map(|body| body.0.clone())
215 })
216 .or_else(|| {
217 request
218 .body
219 .as_deref()
220 .and_then(|body| alibi_core::utils::json::from_slice(body).ok())
221 });
222 let new_session = request
223 .extensions
224 .get::<super::helpers::CompletedSession<S>>()
225 .map(|completed| LastLoginMethodSession {
226 user: completed.callback_user(ctx),
227 session: completed.callback_session(ctx),
228 });
229 let endpoint = request
230 .extensions
231 .get::<alibi_core::plugin::ResolvedEndpoint>();
232 let route_path = endpoint
233 .as_ref()
234 .map_or_else(|| request.path.clone(), |endpoint| endpoint.path.clone());
235 let params = endpoint
236 .map(|endpoint| endpoint.params.clone())
237 .unwrap_or_default();
238 Some(LastLoginMethodContext {
239 body,
240 request: Some(request),
241 endpoint: None,
242 endpoint_response: None,
243 route_path,
244 params,
245 response: None,
246 new_session,
247 })
248 }
249 fn endpoint_context(call: &alibi_core::endpoint::EndpointCall) -> LastLoginMethodContext {
250 let new_session = alibi_core::session::cookie_cache::runtime::published_session(call)
251 .map(|(user, session)| LastLoginMethodSession { user, session });
252 let params = call
253 .params()
254 .and_then(|value| value.as_object())
255 .map(|values| {
256 values
257 .iter()
258 .filter_map(|(key, value)| {
259 value.as_str().map(|value| (key.clone(), value.to_owned()))
260 })
261 .collect()
262 })
263 .unwrap_or_default();
264 LastLoginMethodContext {
265 request: call.request().map(RequestHookContext::from_request),
266 endpoint: Some(call.clone()),
267 endpoint_response: None,
268 body: call.body().cloned(),
269 route_path: call.path().unwrap_or_default().to_owned(),
270 params,
271 response: None,
272 new_session,
273 }
274 }
275 fn cookie(&self, method: &str, config: &alibi_core::AuthConfig) -> AuthResult<String> {
276 if self.config.max_age > 34_560_000.0 {
277 return Err(alibi_core::AuthError::CallbackFailure(Box::new(
278 alibi_core::AuthError::internal("Invalid login-method cookie lifetime"),
279 )));
280 }
281 let defaults = &config.advanced.default_cookie_attributes;
282 let overrides = config
283 .advanced
284 .cookies
285 .get("session_token")
286 .map(|cookie| &cookie.attributes);
287 let secure = overrides
288 .and_then(|attrs| attrs.secure)
289 .or(defaults.secure)
290 .unwrap_or(config.session.cookie_secure);
291 let same_site = overrides
292 .and_then(|attrs| attrs.same_site.as_ref())
293 .or(defaults.same_site.as_ref())
294 .unwrap_or(&config.session.cookie_same_site);
295 let path = overrides
296 .and_then(|attrs| attrs.path.as_deref())
297 .or(defaults.path.as_deref())
298 .unwrap_or("/");
299 let domain = overrides
300 .and_then(|attrs| attrs.domain.as_deref())
301 .or(defaults.domain.as_deref())
302 .or_else(|| {
303 config
304 .advanced
305 .cross_sub_domain_cookies
306 .as_ref()
307 .map(|cross| cross.domain.as_str())
308 });
309 let value = urlencoding::encode(method)
310 .replace("%21", "!")
311 .replace("%27", "'")
312 .replace("%28", "(")
313 .replace("%29", ")")
314 .replace("%2A", "*");
315 let mut cookie = cookie::Cookie::build((self.config.cookie_name.clone(), value))
316 .path(path)
317 .secure(secure)
318 .http_only(false)
319 .same_site(match same_site {
320 alibi_core::config::SameSite::Strict => cookie::SameSite::Strict,
321 alibi_core::config::SameSite::Lax => cookie::SameSite::Lax,
322 alibi_core::config::SameSite::None => cookie::SameSite::None,
323 });
324 if let Some(domain) = domain {
325 cookie = cookie.domain(domain.to_owned());
326 }
327 if self.config.max_age >= 0.0 {
328 #[expect(
329 clippy::as_conversions,
330 clippy::cast_possible_truncation,
331 reason = "Cookie max age is finite, bounded, and floors nonnegative JavaScript seconds"
332 )]
333 let seconds = self.config.max_age.floor() as i64;
334 cookie = cookie.max_age(cookie::time::Duration::seconds(seconds));
335 }
336 Ok(cookie.build().to_string())
337 }
338}
339#[async_trait]
340impl<S: AuthSchema> AuthPlugin<S> for LastLoginMethodPlugin {
341 route_openapi_metadata!(S);
342
343 fn name(&self) -> &'static str {
344 "last-login-method"
345 }
346 fn routes(&self) -> Vec<AuthRoute> {
347 Vec::new()
348 }
349 fn endpoint_hooks(&self) -> Vec<&dyn alibi_core::endpoint::EndpointHook<S>> {
350 vec![self]
351 }
352
353 async fn on_init(&self, ctx: &mut AuthInitContext<S>) -> AuthResult<()> {
354 ctx.set_metadata(
355 "last-login-method.store-in-database",
356 serde_json::json!(self.config.store_in_database),
357 );
358 if !self.config.store_in_database {
359 return Ok(());
360 }
361 ctx.set_metadata("last-login-method.enabled", serde_json::json!(true));
362 let plugin = self.clone();
363 let runtime = Arc::new(AuthContext {
364 config: Arc::clone(&ctx.config),
365 database: Arc::clone(&ctx.database),
366 email_provider: ctx.email_provider.clone(),
367 metadata: ctx.metadata.clone(),
368 extensions: ctx.extensions.clone(),
369 });
370 let runtime_create = Arc::clone(&runtime);
371 ctx.register_user_create_transform(move |mut data| {
372 if let Some(context) = Self::adapter_context(&runtime_create)
373 && let Some(method) = plugin
374 .resolve(&context)?
375 .filter(|method| !method.is_empty())
376 {
377 data.last_login_method = Some(method);
378 }
379 Ok(data)
380 });
381 ctx.register_session_created_hook(Arc::new(LastLoginSessionHook {
382 plugin: self.clone(),
383 context: runtime,
384 }));
385 Ok(())
386 }
387 async fn on_request(
388 &self,
389 _req: &AuthRequest,
390 _ctx: &AuthContext<S>,
391 ) -> AuthResult<Option<AuthResponse>> {
392 Ok(None)
393 }
394 async fn after_request(
395 &self,
396 req: &AuthRequest,
397 ctx: &AuthContext<S>,
398 mut response: AuthResponse,
399 ) -> AuthResult<AuthResponse> {
400 let new_session = super::helpers::completed_response_session(req, ctx, &response)
401 .map(|completed| LastLoginMethodSession {
402 user: completed.callback_user(ctx),
403 session: completed.callback_session(ctx),
404 })
405 .or_else(|| {
406 alibi_core::session::cookie_cache::runtime::published_session(req)
407 .map(|(user, session)| LastLoginMethodSession { user, session })
408 });
409 let mut request =
410 current_request_hook_context().unwrap_or_else(|| RequestHookContext::from_request(req));
411 request.path = request
412 .path
413 .strip_prefix(&ctx.config.base_path)
414 .unwrap_or(&request.path)
415 .to_owned();
416 request.headers.clone_from(&req.headers);
418 let endpoint = req
419 .extensions()
420 .get::<alibi_core::plugin::ResolvedEndpoint>();
421 let context = LastLoginMethodContext {
422 route_path: endpoint
423 .as_ref()
424 .map_or_else(|| req.path().to_owned(), |endpoint| endpoint.path.clone()),
425 params: endpoint
426 .map(|endpoint| endpoint.params.clone())
427 .unwrap_or_default(),
428 body: req
429 .extensions()
430 .get::<alibi_core::hooks::TransformedRequestBody>()
431 .map(|body| body.0.clone())
432 .or_else(|| {
433 req.extensions()
434 .get::<alibi_core::types::ParsedRequestBody>()
435 .and_then(|body| match body.as_ref() {
436 alibi_core::types::ParsedRequestBody::Value(body) => Some(body.clone()),
437 alibi_core::types::ParsedRequestBody::Opaque(_) => None,
438 })
439 })
440 .or_else(|| {
441 req.body
442 .as_deref()
443 .and_then(|body| alibi_core::utils::json::from_slice(body).ok())
444 }),
445 request: Some(request),
446 endpoint: None,
447 endpoint_response: None,
448 response: Some(response.clone()),
449 new_session,
450 };
451 if let Some(method) = self.resolve(&context)?.filter(|method| !method.is_empty())
452 && response
453 .headers
454 .get_all("set-cookie")
455 .any(|header| header.contains(&ctx.config.session.cookie_name))
456 {
457 let permitted = match &self.config.before_store_cookie {
458 None => true,
459 Some(callback) => match callback.before_store(&context, &method).await {
460 Ok(permitted) => permitted,
461 Err(error) => {
462 tracing::error!(%error,"[LastLoginMethod] Error in beforeStoreCookie hook");
463 false
464 }
465 },
466 };
467 if permitted {
468 response
469 .headers
470 .append("set-cookie", self.cookie(&method, &ctx.config)?);
471 }
472 }
473 Ok(response)
474 }
475}
476
477#[async_trait]
478impl<S: AuthSchema> alibi_core::endpoint::EndpointHook<S> for LastLoginMethodPlugin {
479 async fn after(
480 &self,
481 call: &alibi_core::endpoint::EndpointCall,
482 ctx: &AuthContext<S>,
483 mut response: alibi_core::endpoint::EndpointResponse,
484 ) -> AuthResult<alibi_core::endpoint::EndpointResponse> {
485 let mut context = Self::endpoint_context(call);
486 context.endpoint_response = Some(LastLoginMethodEndpointResponse {
487 value: response.result().as_ref().ok().cloned(),
488 error_body: response.error_body().cloned(),
489 headers: response.headers().clone(),
490 status: response.status(),
491 });
492 if let Some(method) = self.resolve(&context)?.filter(|method| !method.is_empty())
493 && response
494 .headers()
495 .get_all("set-cookie")
496 .any(|header| header.contains(&ctx.config.session.cookie_name))
497 {
498 let permitted = match &self.config.before_store_cookie {
499 None => true,
500 Some(callback) => match callback.before_store(&context, &method).await {
501 Ok(permitted) => permitted,
502 Err(error) => {
503 tracing::error!(%error,"[LastLoginMethod] Error in beforeStoreCookie hook");
504 false
505 }
506 },
507 };
508 if permitted {
509 response = response.with_header("set-cookie", self.cookie(&method, &ctx.config)?);
510 }
511 }
512 Ok(response)
513 }
514}
515
516struct LastLoginSessionHook<S: AuthSchema> {
517 plugin: LastLoginMethodPlugin,
518 context: Arc<AuthContext<S>>,
519}
520#[async_trait]
521impl<S: AuthSchema> alibi_core::store::SessionCreatedHook<S> for LastLoginSessionHook<S> {
522 async fn after_create(
523 &self,
524 session: &S::Session,
525 database: &dyn alibi_core::store::AuthStore<S>,
526 ) -> AuthResult<()> {
527 if let Some(context) = LastLoginMethodPlugin::adapter_context(&self.context)
528 && let Some(method) = self
529 .plugin
530 .resolve(&context)?
531 .filter(|method| !method.is_empty())
532 && let Err(error) = database
533 .update_user_record(
534 session.user_id().as_ref(),
535 UpdateUser {
536 last_login_method: Some(Some(method)),
537 ..Default::default()
538 },
539 )
540 .await
541 {
542 tracing::error!(%error,"Failed to update lastLoginMethod");
543 }
544 Ok(())
545 }
546}
547
548pub fn reject_last_login_method_input<S: AuthSchema>(
554 ctx: &AuthContext<S>,
555 value: Option<&alibi_core::utils::json::JsValue>,
556) -> AuthResult<()> {
557 use alibi_core::utils::json::JsValue;
558 let truthy = match value {
559 None | Some(JsValue::Null) => false,
560 Some(JsValue::Bool(value)) => *value,
561 Some(JsValue::Number(value)) => *value != 0.0 && !value.is_nan(),
562 Some(JsValue::String(value)) => !value.is_empty(),
563 Some(JsValue::Date(_) | JsValue::Array(_) | JsValue::Object(_)) => true,
564 };
565 if ctx
566 .get_metadata("last-login-method.enabled")
567 .and_then(serde_json::Value::as_bool)
568 == Some(true)
569 && truthy
570 {
571 return Err(alibi_core::AuthError::Upstream {
572 status: 400,
573 code: codes::FIELD_NOT_ALLOWED,
574 message: "lastLoginMethod is not allowed to be set",
575 });
576 }
577 Ok(())
578}