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    pub request: RequestHookContext,
15    /// Endpoint input at this callback phase; original HTTP bytes remain in request.body.
16    pub body: Option<alibi_core::utils::json::JsValue>,
17    /// Admitted endpoint template; original request.path remains the actual URI.
18    pub route_path: String,
19    pub params: std::collections::HashMap<String, String>,
20    /// Present only at the completed-response boundary. Contains original
21    /// returned bytes and accumulated cookies, including authentication failures.
22    pub response: Option<AuthResponse>,
23    /// Original trusted issuance snapshot; later user-row writes do not replace it.
24    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}
31/// A synchronous application resolver. `None` falls back to the built-in resolver;
32/// an empty string suppresses tracking. Errors propagate at the invoking boundary.
33pub trait ResolveLastLoginMethod: Send + Sync {
34    fn resolve(&self, context: &LastLoginMethodContext) -> AuthResult<Option<String>>;
35}
36/// Cookie consent does not decide whether the authenticated session may persist.
37#[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
377/// Reject caller-supplied tracking values when the registered database field is
378/// server managed. Call after the endpoint's ordinary admission validation.
379///
380/// # Errors
381/// Returns the published `FIELD_NOT_ALLOWED` response for truthy supplied values.
382pub 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}