Skip to main content

alibi_api/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(config
195                .advanced
196                .cross_sub_domain_cookies
197                .as_ref()
198                .map(|cross| cross.domain.as_str()));
199        let value = urlencoding::encode(method)
200            .replace("%21", "!")
201            .replace("%27", "'")
202            .replace("%28", "(")
203            .replace("%29", ")")
204            .replace("%2A", "*");
205        let mut cookie = cookie::Cookie::build((self.config.cookie_name.clone(), value))
206            .path(path)
207            .secure(secure)
208            .http_only(false)
209            .same_site(match same_site {
210                alibi_core::config::SameSite::Strict => cookie::SameSite::Strict,
211                alibi_core::config::SameSite::Lax => cookie::SameSite::Lax,
212                alibi_core::config::SameSite::None => cookie::SameSite::None,
213            });
214        if let Some(domain) = domain {
215            cookie = cookie.domain(domain.to_owned());
216        }
217        if self.config.max_age >= 0.0 {
218            #[expect(
219                clippy::as_conversions,
220                clippy::cast_possible_truncation,
221                reason = "Cookie max age is finite, bounded, and floors nonnegative JavaScript seconds"
222            )]
223            let seconds = self.config.max_age.floor() as i64;
224            cookie = cookie.max_age(cookie::time::Duration::seconds(seconds));
225        }
226        Ok(cookie.build().to_string())
227    }
228}
229#[async_trait]
230impl<S: AuthSchema> AuthPlugin<S> for LastLoginMethodPlugin {
231    fn static_openapi_metadata(&self) -> alibi_core::PluginOpenApiMetadata {
232        crate::metadata::plugin_metadata(
233            <Self as alibi_core::AuthPlugin<S>>::name(self),
234            &<Self as alibi_core::AuthPlugin<S>>::routes(self),
235        )
236    }
237
238    fn openapi_metadata(
239        &self,
240        ctx: &alibi_core::AuthInitContext<S>,
241    ) -> alibi_core::PluginOpenApiMetadata {
242        crate::metadata::instance_plugin_metadata(
243            <Self as alibi_core::AuthPlugin<S>>::name(self),
244            &<Self as alibi_core::AuthPlugin<S>>::routes(self),
245            ctx,
246        )
247    }
248
249    fn name(&self) -> &'static str {
250        "last-login-method"
251    }
252    fn routes(&self) -> Vec<AuthRoute> {
253        Vec::new()
254    }
255    async fn on_init(&self, ctx: &mut AuthInitContext<S>) -> AuthResult<()> {
256        ctx.set_metadata(
257            "last-login-method.store-in-database",
258            serde_json::json!(self.config.store_in_database),
259        );
260        if !self.config.store_in_database {
261            return Ok(());
262        }
263        ctx.set_metadata("last-login-method.enabled", serde_json::json!(true));
264        let plugin = self.clone();
265        let runtime = Arc::new(AuthContext {
266            config: Arc::clone(&ctx.config),
267            database: Arc::clone(&ctx.database),
268            email_provider: ctx.email_provider.clone(),
269            metadata: ctx.metadata.clone(),
270            extensions: ctx.extensions.clone(),
271        });
272        let runtime_create = Arc::clone(&runtime);
273        ctx.register_user_create_transform(move |mut data| {
274            if let Some(context) = Self::adapter_context(&runtime_create)
275                && let Some(method) = plugin
276                    .resolve(&context)?
277                    .filter(|method| !method.is_empty())
278            {
279                data.last_login_method = Some(method);
280            }
281            Ok(data)
282        });
283        ctx.register_session_created_hook(Arc::new(LastLoginSessionHook {
284            plugin: self.clone(),
285            context: runtime,
286        }));
287        Ok(())
288    }
289    async fn on_request(
290        &self,
291        _req: &AuthRequest,
292        _ctx: &AuthContext<S>,
293    ) -> AuthResult<Option<AuthResponse>> {
294        Ok(None)
295    }
296    async fn after_request(
297        &self,
298        req: &AuthRequest,
299        ctx: &AuthContext<S>,
300        mut response: AuthResponse,
301    ) -> AuthResult<AuthResponse> {
302        let new_session =
303            super::helpers::completed_response_session(req, ctx, &response).map(|completed| {
304                LastLoginMethodSession {
305                    user: completed.callback_user(ctx),
306                    session: completed.callback_session(ctx),
307                }
308            });
309        let endpoint = req
310            .extensions()
311            .get::<alibi_core::plugin::ResolvedEndpoint>();
312        let context = LastLoginMethodContext {
313            route_path: endpoint
314                .as_ref()
315                .map_or_else(|| req.path().to_owned(), |endpoint| endpoint.path.clone()),
316            params: endpoint
317                .map(|endpoint| endpoint.params.clone())
318                .unwrap_or_default(),
319            body: req
320                .extensions()
321                .get::<alibi_core::hooks::TransformedRequestBody>()
322                .map(|body| body.0.clone())
323                .or_else(|| {
324                    req.body
325                        .as_deref()
326                        .and_then(|body| alibi_core::utils::json::from_slice(body).ok())
327                }),
328            request: RequestHookContext::from_request(req),
329            response: Some(response.clone()),
330            new_session,
331        };
332        if let Some(method) = self.resolve(&context)?.filter(|method| !method.is_empty())
333            && response
334                .headers
335                .get_all("set-cookie")
336                .any(|header| header.contains(&ctx.config.session.cookie_name))
337        {
338            let permitted = match &self.config.before_store_cookie {
339                None => true,
340                Some(callback) => match callback.before_store(&context, &method).await {
341                    Ok(permitted) => permitted,
342                    Err(error) => {
343                        tracing::error!(%error,"[LastLoginMethod] Error in beforeStoreCookie hook");
344                        false
345                    }
346                },
347            };
348            if permitted {
349                response
350                    .headers
351                    .append("set-cookie", self.cookie(&method, &ctx.config)?);
352            }
353        }
354        Ok(response)
355    }
356}
357
358struct LastLoginSessionHook<S: AuthSchema> {
359    plugin: LastLoginMethodPlugin,
360    context: Arc<AuthContext<S>>,
361}
362#[async_trait]
363impl<S: AuthSchema> alibi_core::store::SessionCreatedHook<S> for LastLoginSessionHook<S> {
364    async fn after_create(
365        &self,
366        session: &S::Session,
367        database: &dyn alibi_core::store::AuthStore<S>,
368    ) -> AuthResult<()> {
369        if let Some(context) = LastLoginMethodPlugin::adapter_context(&self.context)
370            && let Some(method) = self
371                .plugin
372                .resolve(&context)?
373                .filter(|method| !method.is_empty())
374            && let Err(error) = database
375                .update_user_record(
376                    session.user_id().as_ref(),
377                    UpdateUser {
378                        last_login_method: Some(Some(method)),
379                        ..Default::default()
380                    },
381                )
382                .await
383        {
384            tracing::error!(%error,"Failed to update lastLoginMethod");
385        }
386        Ok(())
387    }
388}
389
390/// Reject caller-supplied tracking values when the registered database field is
391/// server managed. Call after the endpoint's ordinary admission validation.
392///
393/// # Errors
394/// Returns the published FIELD_NOT_ALLOWED response for truthy supplied values.
395pub fn reject_last_login_method_input<S: AuthSchema>(
396    ctx: &AuthContext<S>,
397    value: Option<&alibi_core::utils::json::JsValue>,
398) -> AuthResult<()> {
399    use alibi_core::utils::json::JsValue;
400    let truthy = match value {
401        None | Some(JsValue::Null) => false,
402        Some(JsValue::Bool(value)) => *value,
403        Some(JsValue::Number(value)) => *value != 0.0 && !value.is_nan(),
404        Some(JsValue::String(value)) => !value.is_empty(),
405        Some(JsValue::Array(_) | JsValue::Object(_)) => true,
406    };
407    if ctx
408        .get_metadata("last-login-method.enabled")
409        .and_then(serde_json::Value::as_bool)
410        == Some(true)
411        && truthy
412    {
413        return Err(alibi_core::AuthError::Upstream {
414            status: 400,
415            code: "FIELD_NOT_ALLOWED",
416            message: "lastLoginMethod is not allowed to be set",
417        });
418    }
419    Ok(())
420}