Skip to main content

alibi_plugins/
last_login_method.rs

1//! Remember successful session issuance without changing authentication authority.
2use 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/// Request context retained at the actual adapter or completed-response boundary.
12#[derive(Clone, Debug)]
13pub struct LastLoginMethodContext {
14    /// The actual HTTP request, absent for header-only server calls.
15    pub request: Option<RequestHookContext>,
16    /// The genuine logical call when invoked through the server SDK.
17    pub endpoint: Option<alibi_core::endpoint::EndpointCall>,
18    /// Actual completed logical output; HTTP callbacks use `response` instead.
19    pub endpoint_response: Option<LastLoginMethodEndpointResponse>,
20    /// Endpoint input at this callback phase; original HTTP bytes remain in request.body.
21    pub body: Option<alibi_core::utils::json::JsValue>,
22    /// Admitted endpoint template; original request.path remains the actual URI.
23    pub route_path: String,
24    pub params: std::collections::HashMap<String, String>,
25    /// Present only at the completed-response boundary. Contains original
26    /// returned bytes and accumulated cookies, including authentication failures.
27    pub response: Option<AuthResponse>,
28    /// Original trusted issuance snapshot; later user-row writes do not replace it.
29    pub new_session: Option<LastLoginMethodSession>,
30}
31/// Output retained from an actual completed server operation.
32#[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    /// Headers at the actual invocation boundary, independently of a Request.
42    #[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}
71/// A synchronous application resolver. `None` falls back to the built-in resolver;
72/// an empty string suppresses tracking. Errors propagate at the invoking boundary.
73pub trait ResolveLastLoginMethod: Send + Sync {
74    fn resolve(&self, context: &LastLoginMethodContext) -> AuthResult<Option<String>>;
75}
76/// Cookie consent does not decide whether the authenticated session may persist.
77#[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
493/// Reject caller-supplied tracking values when the registered database field is
494/// server managed. Call after the endpoint's ordinary admission validation.
495///
496/// # Errors
497/// Returns the published `FIELD_NOT_ALLOWED` response for truthy supplied values.
498pub 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}