Skip to main content

alibi_plugins/
last_login_method.rs

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