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