Skip to main content

better_auth_core/
plugin.rs

1use async_trait::async_trait;
2use std::collections::HashMap;
3use std::sync::Arc;
4
5use crate::config::AuthConfig;
6use crate::email::EmailProvider;
7use crate::entity::AuthSession;
8use crate::error::{AuthError, AuthResult};
9use crate::schema::AuthSchema;
10#[cfg(test)]
11use crate::session::SessionManager;
12use crate::store::AuthStore;
13use crate::types::{AuthRequest, AuthResponse, HttpMethod};
14
15type MetadataMap = HashMap<String, serde_json::Value>;
16
17pub struct AuthInitParts {
18    pub metadata: MetadataMap,
19    pub email_provider: Option<Arc<dyn EmailProvider>>,
20}
21
22/// Action returned by [`AuthPlugin::before_request`].
23#[derive(Debug)]
24pub enum BeforeRequestAction {
25    /// Short-circuit with this response (e.g. return session JSON).
26    Respond(AuthResponse),
27    /// Inject a virtual session so downstream handlers see it as authenticated.
28    InjectSession {
29        user_id: String,
30        session_token: String,
31    },
32}
33
34/// Plugin trait that all authentication plugins must implement.
35///
36#[async_trait]
37pub trait AuthPlugin<S: AuthSchema>: Send + Sync {
38    /// Plugin name - should be unique
39    fn name(&self) -> &'static str;
40
41    /// Routes that this plugin handles
42    fn routes(&self) -> Vec<AuthRoute>;
43
44    /// Called when the plugin is initialized
45    async fn on_init(&self, ctx: &mut AuthInitContext<S>) -> AuthResult<()> {
46        let _ = ctx;
47        Ok(())
48    }
49
50    /// Called before route matching for every incoming request.
51    ///
52    /// Return `Some(BeforeRequestAction::Respond(..))` to short-circuit with a
53    /// response, `Some(BeforeRequestAction::InjectSession { .. })` to attach a
54    /// virtual session (e.g. API-key → session emulation), or `None` to let the
55    /// request continue to normal route matching.
56    async fn before_request(
57        &self,
58        _req: &AuthRequest,
59        _ctx: &AuthContext<S>,
60    ) -> AuthResult<Option<BeforeRequestAction>> {
61        Ok(None)
62    }
63
64    /// Called for each request - return Some(response) to handle, None to pass through
65    async fn on_request(
66        &self,
67        req: &AuthRequest,
68        ctx: &AuthContext<S>,
69    ) -> AuthResult<Option<AuthResponse>>;
70
71    /// Called after a user is created
72    async fn on_user_created(&self, user: &S::User, ctx: &AuthContext<S>) -> AuthResult<()> {
73        let _ = (user, ctx);
74        Ok(())
75    }
76
77    /// Called after a session is created
78    async fn on_session_created(
79        &self,
80        session: &S::Session,
81        ctx: &AuthContext<S>,
82    ) -> AuthResult<()> {
83        let _ = (session, ctx);
84        Ok(())
85    }
86
87    /// Called before a user is deleted
88    async fn on_user_deleted(&self, user_id: &str, ctx: &AuthContext<S>) -> AuthResult<()> {
89        let _ = (user_id, ctx);
90        Ok(())
91    }
92
93    /// Called before a session is deleted
94    async fn on_session_deleted(
95        &self,
96        session_token: &str,
97        ctx: &AuthContext<S>,
98    ) -> AuthResult<()> {
99        let _ = (session_token, ctx);
100        Ok(())
101    }
102}
103
104/// Generates the [`AuthPlugin`] impl for a plugin with static route dispatch.
105///
106/// Eliminates the dual declaration of routes in `routes()` and `on_request()`
107/// by generating both from a single route table.
108///
109/// # Exceptions (must keep manual impl)
110/// - `OAuthPlugin` — dynamic path matching for `/callback/{provider}`
111/// - `SessionManagementPlugin` — match guards and OR patterns
112/// - `EmailPasswordPlugin` — conditional routes based on config
113/// - `UserManagementPlugin` — conditional routes based on config
114/// - `PasswordManagementPlugin` — dynamic path matching for `/reset-password/{token}`
115/// - `OrganizationPlugin` — handlers accept extra `&self.config` argument
116#[macro_export]
117macro_rules! impl_auth_plugin {
118    (@pat get) => { $crate::HttpMethod::Get };
119    (@pat post) => { $crate::HttpMethod::Post };
120    (@pat put) => { $crate::HttpMethod::Put };
121    (@pat delete) => { $crate::HttpMethod::Delete };
122    (@pat patch) => { $crate::HttpMethod::Patch };
123    (@pat head) => { $crate::HttpMethod::Head };
124
125    (@route get) => { $crate::AuthRoute::get };
126    (@route post) => { $crate::AuthRoute::post };
127    (@route put) => { $crate::AuthRoute::put };
128    (@route delete) => { $crate::AuthRoute::delete };
129
130    (
131        $plugin:ty, $name:expr;
132        routes {
133            $( $method:ident $path:literal => $handler:ident, $op_id:literal );* $(;)?
134        }
135        $( extra { $($extra:tt)* } )?
136    ) => {
137        #[::async_trait::async_trait]
138        impl<S: $crate::AuthSchema> $crate::AuthPlugin<S> for $plugin {
139            fn name(&self) -> &'static str { $name }
140
141            fn routes(&self) -> Vec<$crate::AuthRoute> {
142                vec![
143                    $( $crate::AuthRoute::new($crate::impl_auth_plugin!(@pat $method), $path, $op_id), )*
144                ]
145            }
146
147            async fn on_request(
148                &self,
149                req: &$crate::AuthRequest,
150                ctx: &$crate::AuthContext<S>,
151            ) -> $crate::AuthResult<Option<$crate::AuthResponse>> {
152                match (req.method(), req.path()) {
153                    $(
154                        ($crate::impl_auth_plugin!(@pat $method), $path) => {
155                            Ok(Some(self.$handler(req, ctx).await?))
156                        }
157                    )*
158                    _ => Ok(None),
159                }
160            }
161
162            $( $($extra)* )?
163        }
164    };
165}
166
167/// Route definition for plugins
168#[derive(Debug, Clone)]
169pub struct AuthRoute {
170    pub path: String,
171    pub method: HttpMethod,
172    /// Identifier used as the OpenAPI `operationId` for this route.
173    pub operation_id: String,
174}
175
176/// Initialization context passed to plugin setup.
177pub struct AuthInitContext<S: AuthSchema> {
178    pub config: Arc<AuthConfig>,
179    pub database: Arc<dyn AuthStore<S>>,
180    pub email_provider: Option<Arc<dyn EmailProvider>>,
181    pub metadata: MetadataMap,
182}
183
184/// Context passed to plugin methods.
185pub struct AuthContext<S: AuthSchema> {
186    pub config: Arc<AuthConfig>,
187    pub database: Arc<dyn AuthStore<S>>,
188    pub email_provider: Option<Arc<dyn EmailProvider>>,
189    pub metadata: MetadataMap,
190}
191
192impl AuthRoute {
193    pub fn new(
194        method: HttpMethod,
195        path: impl Into<String>,
196        operation_id: impl Into<String>,
197    ) -> Self {
198        Self {
199            path: path.into(),
200            method,
201            operation_id: operation_id.into(),
202        }
203    }
204
205    pub fn get(path: impl Into<String>, operation_id: impl Into<String>) -> Self {
206        Self::new(HttpMethod::Get, path, operation_id)
207    }
208
209    pub fn post(path: impl Into<String>, operation_id: impl Into<String>) -> Self {
210        Self::new(HttpMethod::Post, path, operation_id)
211    }
212
213    pub fn put(path: impl Into<String>, operation_id: impl Into<String>) -> Self {
214        Self::new(HttpMethod::Put, path, operation_id)
215    }
216
217    pub fn delete(path: impl Into<String>, operation_id: impl Into<String>) -> Self {
218        Self::new(HttpMethod::Delete, path, operation_id)
219    }
220}
221
222impl<S: AuthSchema> AuthInitContext<S> {
223    pub fn new(config: Arc<AuthConfig>, database: Arc<dyn AuthStore<S>>) -> Self {
224        let email_provider = config.email_provider.clone();
225        Self {
226            config,
227            database,
228            email_provider,
229            metadata: MetadataMap::new(),
230        }
231    }
232
233    pub fn set_metadata(&mut self, key: impl Into<String>, value: serde_json::Value) {
234        _ = self.metadata.insert(key.into(), value);
235    }
236
237    pub fn get_metadata(&self, key: &str) -> Option<&serde_json::Value> {
238        self.metadata.get(key)
239    }
240
241    pub fn into_parts(self) -> AuthInitParts {
242        AuthInitParts {
243            metadata: self.metadata,
244            email_provider: self.email_provider,
245        }
246    }
247}
248
249impl<S: AuthSchema> AuthContext<S> {
250    pub fn new(config: Arc<AuthConfig>, database: Arc<dyn AuthStore<S>>) -> Self {
251        let email_provider = config.email_provider.clone();
252        Self {
253            config,
254            database,
255            email_provider,
256            metadata: MetadataMap::new(),
257        }
258    }
259
260    pub fn with_metadata(
261        config: Arc<AuthConfig>,
262        database: Arc<dyn AuthStore<S>>,
263        metadata: MetadataMap,
264    ) -> Self {
265        let email_provider = config.email_provider.clone();
266        Self {
267            config,
268            database,
269            email_provider,
270            metadata,
271        }
272    }
273
274    pub fn set_metadata(&mut self, key: impl Into<String>, value: serde_json::Value) {
275        _ = self.metadata.insert(key.into(), value);
276    }
277
278    pub fn get_metadata(&self, key: &str) -> Option<&serde_json::Value> {
279        self.metadata.get(key)
280    }
281
282    /// Get the email provider, returning an error if none is configured.
283    pub fn email_provider(&self) -> AuthResult<&dyn EmailProvider> {
284        self.email_provider
285            .as_deref()
286            .ok_or_else(|| AuthError::config("No email provider configured"))
287    }
288
289    /// Create a `SessionManager` from this context's config and database.
290    pub fn session_manager(&self) -> crate::session::SessionManager<S> {
291        crate::session::SessionManager::new(self.config.clone(), self.database.clone())
292    }
293
294    /// Extract a session token from the request, validate the session, and
295    /// return the authenticated `(User, Session)` pair.
296    ///
297    /// This centralises the pattern previously duplicated across many plugins
298    /// (`get_authenticated_user`, `require_session`, etc.).
299    pub async fn require_session(&self, req: &AuthRequest) -> AuthResult<(S::User, S::Session)> {
300        let session_manager = self.session_manager();
301
302        if let Some(token) = session_manager.extract_session_token(req)
303            && let Some(session) = session_manager.get_session(&token).await?
304            && let Some(user) = self.database.get_user_by_id(&session.user_id()).await?
305        {
306            return Ok((user, session));
307        }
308
309        Err(AuthError::Unauthenticated)
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316    use crate::entity::AuthUser;
317    use crate::test_store::test_database;
318
319    // Rust-specific surface: plugin infrastructure helpers and request-dispatch helpers in `crates/core::plugin` are Rust library APIs with no direct TS analogue.
320    #[test]
321    fn auth_route_constructors() {
322        let get = AuthRoute::get("/test", "getTest");
323        assert_eq!(get.method, HttpMethod::Get);
324        assert_eq!(get.path, "/test");
325        assert_eq!(get.operation_id, "getTest");
326
327        let post = AuthRoute::post("/create", "createItem");
328        assert_eq!(post.method, HttpMethod::Post);
329
330        let put = AuthRoute::put("/update", "updateItem");
331        assert_eq!(put.method, HttpMethod::Put);
332
333        let delete = AuthRoute::delete("/remove", "deleteItem");
334        assert_eq!(delete.method, HttpMethod::Delete);
335    }
336
337    // Rust-specific surface: plugin infrastructure helpers and request-dispatch helpers in `crates/core::plugin` are Rust library APIs with no direct TS analogue.
338    #[test]
339    fn auth_route_new() {
340        let route = AuthRoute::new(HttpMethod::Patch, "/patch", "patchIt");
341        assert_eq!(route.method, HttpMethod::Patch);
342        assert_eq!(route.path, "/patch");
343    }
344
345    // Rust-specific surface: plugin infrastructure helpers and request-dispatch helpers in `crates/core::plugin` are Rust library APIs with no direct TS analogue.
346    #[test]
347    fn auth_context_new() {
348        let config = Arc::new(AuthConfig::new("test-secret-min-32-chars-1234567"));
349        let runtime = tokio::runtime::Runtime::new().expect("runtime should build");
350        let db = runtime.block_on(test_database());
351        let ctx = AuthContext::new(config.clone(), db);
352        assert!(ctx.email_provider.is_none());
353        assert!(ctx.metadata.is_empty());
354    }
355
356    // Rust-specific surface: plugin infrastructure helpers and request-dispatch helpers in `crates/core::plugin` are Rust library APIs with no direct TS analogue.
357    #[test]
358    fn auth_context_metadata() {
359        let config = Arc::new(AuthConfig::new("test-secret-min-32-chars-1234567"));
360        let runtime = tokio::runtime::Runtime::new().expect("runtime should build");
361        let db = runtime.block_on(test_database());
362        let mut ctx = AuthContext::new(config, db);
363
364        ctx.set_metadata("key", serde_json::json!("value"));
365        assert_eq!(ctx.get_metadata("key"), Some(&serde_json::json!("value")));
366        assert!(ctx.get_metadata("missing").is_none());
367    }
368
369    // Rust-specific surface: plugin infrastructure helpers and request-dispatch helpers in `crates/core::plugin` are Rust library APIs with no direct TS analogue.
370    #[test]
371    fn auth_context_email_provider_error_when_none() {
372        let config = Arc::new(AuthConfig::new("test-secret-min-32-chars-1234567"));
373        let runtime = tokio::runtime::Runtime::new().expect("runtime should build");
374        let db = runtime.block_on(test_database());
375        let ctx = AuthContext::new(config, db);
376        assert!(ctx.email_provider().is_err());
377    }
378
379    // Rust-specific surface: plugin infrastructure helpers and request-dispatch helpers in `crates/core::plugin` are Rust library APIs with no direct TS analogue.
380    #[tokio::test]
381    async fn auth_context_require_session_unauthenticated() {
382        let config = Arc::new(AuthConfig::new("test-secret-min-32-chars-1234567"));
383        let db = test_database().await;
384        let ctx = AuthContext::new(config, db);
385        let req = AuthRequest::new(HttpMethod::Get, "/test");
386        let result = ctx.require_session(&req).await;
387        assert!(result.is_err());
388    }
389
390    // Rust-specific surface: plugin infrastructure helpers and request-dispatch helpers in `crates/core::plugin` are Rust library APIs with no direct TS analogue.
391    #[tokio::test]
392    async fn auth_context_require_session_with_valid_session() {
393        let config = Arc::new(AuthConfig::new("test-secret-min-32-chars-1234567"));
394        let db = test_database().await;
395
396        // Create a user
397        let user = db
398            .create_user(crate::types::CreateUser::new().with_email("test@test.com"))
399            .await
400            .unwrap();
401
402        // Create a session
403        let sm = SessionManager::new(config.clone(), db.clone());
404        let session = sm.create_session(&user, None, None).await.unwrap();
405
406        // Build request with the session token
407        let ctx = AuthContext::new(config.clone(), db);
408        let mut req = AuthRequest::new(HttpMethod::Get, "/test");
409        let _ = req.headers.insert(
410            "cookie".into(),
411            format!("better-auth.session_token={}", session.token()),
412        );
413
414        let (found_user, _found_session) = ctx.require_session(&req).await.unwrap();
415        assert_eq!(found_user.id(), user.id());
416    }
417}