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