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