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_else(|| {
195 config
196 .advanced
197 .cross_sub_domain_cookies
198 .as_ref()
199 .map(|cross| cross.domain.as_str())
200 });
201 let value = urlencoding::encode(method)
202 .replace("%21", "!")
203 .replace("%27", "'")
204 .replace("%28", "(")
205 .replace("%29", ")")
206 .replace("%2A", "*");
207 let mut cookie = cookie::Cookie::build((self.config.cookie_name.clone(), value))
208 .path(path)
209 .secure(secure)
210 .http_only(false)
211 .same_site(match same_site {
212 alibi_core::config::SameSite::Strict => cookie::SameSite::Strict,
213 alibi_core::config::SameSite::Lax => cookie::SameSite::Lax,
214 alibi_core::config::SameSite::None => cookie::SameSite::None,
215 });
216 if let Some(domain) = domain {
217 cookie = cookie.domain(domain.to_owned());
218 }
219 if self.config.max_age >= 0.0 {
220 #[expect(
221 clippy::as_conversions,
222 clippy::cast_possible_truncation,
223 reason = "Cookie max age is finite, bounded, and floors nonnegative JavaScript seconds"
224 )]
225 let seconds = self.config.max_age.floor() as i64;
226 cookie = cookie.max_age(cookie::time::Duration::seconds(seconds));
227 }
228 Ok(cookie.build().to_string())
229 }
230}
231#[async_trait]
232impl<S: AuthSchema> AuthPlugin<S> for LastLoginMethodPlugin {
233 route_openapi_metadata!(S);
234
235 fn name(&self) -> &'static str {
236 "last-login-method"
237 }
238 fn routes(&self) -> Vec<AuthRoute> {
239 Vec::new()
240 }
241 async fn on_init(&self, ctx: &mut AuthInitContext<S>) -> AuthResult<()> {
242 ctx.set_metadata(
243 "last-login-method.store-in-database",
244 serde_json::json!(self.config.store_in_database),
245 );
246 if !self.config.store_in_database {
247 return Ok(());
248 }
249 ctx.set_metadata("last-login-method.enabled", serde_json::json!(true));
250 let plugin = self.clone();
251 let runtime = Arc::new(AuthContext {
252 config: Arc::clone(&ctx.config),
253 database: Arc::clone(&ctx.database),
254 email_provider: ctx.email_provider.clone(),
255 metadata: ctx.metadata.clone(),
256 extensions: ctx.extensions.clone(),
257 });
258 let runtime_create = Arc::clone(&runtime);
259 ctx.register_user_create_transform(move |mut data| {
260 if let Some(context) = Self::adapter_context(&runtime_create)
261 && let Some(method) = plugin
262 .resolve(&context)?
263 .filter(|method| !method.is_empty())
264 {
265 data.last_login_method = Some(method);
266 }
267 Ok(data)
268 });
269 ctx.register_session_created_hook(Arc::new(LastLoginSessionHook {
270 plugin: self.clone(),
271 context: runtime,
272 }));
273 Ok(())
274 }
275 async fn on_request(
276 &self,
277 _req: &AuthRequest,
278 _ctx: &AuthContext<S>,
279 ) -> AuthResult<Option<AuthResponse>> {
280 Ok(None)
281 }
282 async fn after_request(
283 &self,
284 req: &AuthRequest,
285 ctx: &AuthContext<S>,
286 mut response: AuthResponse,
287 ) -> AuthResult<AuthResponse> {
288 let new_session =
289 super::helpers::completed_response_session(req, ctx, &response).map(|completed| {
290 LastLoginMethodSession {
291 user: completed.callback_user(ctx),
292 session: completed.callback_session(ctx),
293 }
294 });
295 let endpoint = req
296 .extensions()
297 .get::<alibi_core::plugin::ResolvedEndpoint>();
298 let context = LastLoginMethodContext {
299 route_path: endpoint
300 .as_ref()
301 .map_or_else(|| req.path().to_owned(), |endpoint| endpoint.path.clone()),
302 params: endpoint
303 .map(|endpoint| endpoint.params.clone())
304 .unwrap_or_default(),
305 body: req
306 .extensions()
307 .get::<alibi_core::hooks::TransformedRequestBody>()
308 .map(|body| body.0.clone())
309 .or_else(|| {
310 req.body
311 .as_deref()
312 .and_then(|body| alibi_core::utils::json::from_slice(body).ok())
313 }),
314 request: RequestHookContext::from_request(req),
315 response: Some(response.clone()),
316 new_session,
317 };
318 if let Some(method) = self.resolve(&context)?.filter(|method| !method.is_empty())
319 && response
320 .headers
321 .get_all("set-cookie")
322 .any(|header| header.contains(&ctx.config.session.cookie_name))
323 {
324 let permitted = match &self.config.before_store_cookie {
325 None => true,
326 Some(callback) => match callback.before_store(&context, &method).await {
327 Ok(permitted) => permitted,
328 Err(error) => {
329 tracing::error!(%error,"[LastLoginMethod] Error in beforeStoreCookie hook");
330 false
331 }
332 },
333 };
334 if permitted {
335 response
336 .headers
337 .append("set-cookie", self.cookie(&method, &ctx.config)?);
338 }
339 }
340 Ok(response)
341 }
342}
343
344struct LastLoginSessionHook<S: AuthSchema> {
345 plugin: LastLoginMethodPlugin,
346 context: Arc<AuthContext<S>>,
347}
348#[async_trait]
349impl<S: AuthSchema> alibi_core::store::SessionCreatedHook<S> for LastLoginSessionHook<S> {
350 async fn after_create(
351 &self,
352 session: &S::Session,
353 database: &dyn alibi_core::store::AuthStore<S>,
354 ) -> AuthResult<()> {
355 if let Some(context) = LastLoginMethodPlugin::adapter_context(&self.context)
356 && let Some(method) = self
357 .plugin
358 .resolve(&context)?
359 .filter(|method| !method.is_empty())
360 && let Err(error) = database
361 .update_user_record(
362 session.user_id().as_ref(),
363 UpdateUser {
364 last_login_method: Some(Some(method)),
365 ..Default::default()
366 },
367 )
368 .await
369 {
370 tracing::error!(%error,"Failed to update lastLoginMethod");
371 }
372 Ok(())
373 }
374}
375
376pub fn reject_last_login_method_input<S: AuthSchema>(
382 ctx: &AuthContext<S>,
383 value: Option<&alibi_core::utils::json::JsValue>,
384) -> AuthResult<()> {
385 use alibi_core::utils::json::JsValue;
386 let truthy = match value {
387 None | Some(JsValue::Null) => false,
388 Some(JsValue::Bool(value)) => *value,
389 Some(JsValue::Number(value)) => *value != 0.0 && !value.is_nan(),
390 Some(JsValue::String(value)) => !value.is_empty(),
391 Some(JsValue::Array(_) | JsValue::Object(_)) => true,
392 };
393 if ctx
394 .get_metadata("last-login-method.enabled")
395 .and_then(serde_json::Value::as_bool)
396 == Some(true)
397 && truthy
398 {
399 return Err(alibi_core::AuthError::Upstream {
400 status: 400,
401 code: "FIELD_NOT_ALLOWED",
402 message: "lastLoginMethod is not allowed to be set",
403 });
404 }
405 Ok(())
406}