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