Skip to main content

alibi_plugins/
custom_session.rs

1//! Application-owned asynchronous session response projection.
2use alibi_core::{
3    AuthContext, AuthPlugin, AuthRequest, AuthResponse, AuthResult, AuthRoute, AuthSchema,
4    HttpMethod,
5};
6use async_trait::async_trait;
7use serde_json::Value;
8use std::sync::Arc;
9
10/// Project an authenticated session response into application response data.
11///
12/// The input is the actual public session/user projection, including configured
13/// fields and deferred-refresh information. Transforming it does not change the
14/// stored principal or the authentication context used by subsequent plugins.
15#[async_trait]
16pub trait SessionTransform<S: AuthSchema>: Send + Sync {
17    /// # Errors
18    /// Return an intentional API error or an application callback failure.
19    async fn transform(
20        &self,
21        session: Value,
22        request: &AuthRequest,
23        context: &AuthContext<S>,
24    ) -> AuthResult<Value>;
25}
26
27/// Replace GET session responses while retaining core authentication and cookies.
28/// Register before an explicitly installed `SessionManagementPlugin` so this
29/// plugin owns the GET route. The replacement accepts GET only, including
30/// when deferred refresh is enabled.
31pub struct CustomSessionPlugin<S: AuthSchema> {
32    transform: Arc<dyn SessionTransform<S>>,
33    mutate_device_sessions: bool,
34}
35
36impl<S: AuthSchema> CustomSessionPlugin<S> {
37    #[must_use]
38    pub fn new(transform: impl SessionTransform<S> + 'static) -> Self {
39        Self {
40            transform: Arc::new(transform),
41            mutate_device_sessions: false,
42        }
43    }
44
45    /// Apply the same projection to each genuine multiple-device-session entry.
46    #[must_use]
47    pub fn mutate_device_sessions(mut self, enabled: bool) -> Self {
48        self.mutate_device_sessions = enabled;
49        self
50    }
51}
52
53#[async_trait]
54impl<S: AuthSchema> AuthPlugin<S> for CustomSessionPlugin<S> {
55    fn static_openapi_metadata(&self) -> alibi_core::PluginOpenApiMetadata {
56        crate::metadata::plugin_metadata(
57            <Self as alibi_core::AuthPlugin<S>>::name(self),
58            &<Self as alibi_core::AuthPlugin<S>>::routes(self),
59        )
60    }
61
62    fn name(&self) -> &'static str {
63        "custom-session"
64    }
65    fn routes(&self) -> Vec<AuthRoute> {
66        vec![
67            AuthRoute::get("/get-session", "get_session"),
68            AuthRoute::post("/get-session", "custom_session_post"),
69        ]
70    }
71
72    fn openapi_metadata(
73        &self,
74        ctx: &alibi_core::AuthInitContext<S>,
75    ) -> alibi_core::openapi::PluginOpenApiMetadata {
76        let mut metadata =
77            crate::metadata::instance_plugin_metadata("session-management", &self.routes(), ctx);
78        // Reuse the base schemas without treating this replacement as a core plugin.
79        metadata.core = false;
80        // Pinned documentation retains the base endpoint and excludes the
81        // replacement that shares its logical API key, despite GET-only HTTP.
82        for (_, _, endpoint) in &mut metadata.endpoints {
83            endpoint.native_extension = true;
84        }
85        metadata
86    }
87
88    async fn on_request(
89        &self,
90        req: &AuthRequest,
91        ctx: &AuthContext<S>,
92    ) -> AuthResult<Option<AuthResponse>> {
93        if req.path() != "/get-session" {
94            return Ok(None);
95        }
96        if req.method() == &HttpMethod::Post {
97            return Ok(Some(AuthResponse::new(404)));
98        }
99        if req.method() != &HttpMethod::Get {
100            return Ok(None);
101        }
102        let prior_headers = req.take_response_headers();
103        let mut response = match super::session_management::SessionManagementPlugin::new()
104            .handle_get_session(req, ctx)
105            .await
106        {
107            Ok(response) => response,
108            Err(_) => AuthResponse::json(200, &Value::Null)?,
109        };
110        let session: Value = if response.status >= 400 {
111            Value::Null
112        } else {
113            serde_json::from_slice(&response.body)?
114        };
115        let core_headers = req.take_response_headers();
116        for (name, value) in prior_headers {
117            req.queue_response_header(name, value);
118        }
119        let (issued_cookies, _) =
120            alibi_core::session::cookie_cache::runtime::take_issuance(req.extensions());
121        if session.is_null() {
122            // The replaced endpoint does not forward headers from a missing or
123            // failed core response. Preserve headers emitted before that read.
124            return Ok(Some(AuthResponse::json(200, &Value::Null)?));
125        }
126        let transformed = self
127            .transform
128            .transform(session, req, ctx)
129            .await
130            .map_err(crate::helpers::callback_failure)?;
131        response.body = serde_json::to_vec(&transformed)?;
132        for (name, value) in core_headers {
133            response.headers.append(name, value);
134        }
135        for cookie in issued_cookies {
136            response.headers.append("Set-Cookie", cookie);
137        }
138        Ok(Some(response))
139    }
140
141    async fn after_request(
142        &self,
143        req: &AuthRequest,
144        ctx: &AuthContext<S>,
145        mut response: AuthResponse,
146    ) -> AuthResult<AuthResponse> {
147        if !self.mutate_device_sessions
148            || req.path() != "/multi-session/list-device-sessions"
149            || response.status >= 400
150        {
151            return Ok(response);
152        }
153        let value: Value = serde_json::from_slice(&response.body)?;
154        if let Value::Array(sessions) = value {
155            let transformed = self.transform_list(sessions, req, ctx).await?;
156            response.body = serde_json::to_vec(&transformed)?;
157        }
158        Ok(response)
159    }
160}
161
162impl<S: AuthSchema> CustomSessionPlugin<S> {
163    async fn transform_list(
164        &self,
165        sessions: Vec<Value>,
166        request: &AuthRequest,
167        context: &AuthContext<S>,
168    ) -> AuthResult<Vec<Value>> {
169        if sessions.is_empty() {
170            return Ok(Vec::new());
171        }
172        let mut results = vec![None; sessions.len()];
173        let transform = self.transform.clone();
174        let request = request.clone();
175        let context = AuthContext::<S> {
176            config: context.config.clone(),
177            database: context.database.clone(),
178            email_provider: context.email_provider.clone(),
179            metadata: context.metadata.clone(),
180            extensions: context.extensions.clone(),
181        };
182        let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
183        // Launched callbacks retain ownership after the aggregate rejects,
184        // matching the independent application work of a device-session list.
185        crate::helpers::spawn_in_request_context(async move {
186            _ = futures_util::future::join_all(sessions.into_iter().enumerate().map(
187                |(index, session)| {
188                    let transform = &transform;
189                    let request = &request;
190                    let context = &context;
191                    let sender = &sender;
192                    async move {
193                        let result = transform
194                            .transform(session, request, context)
195                            .await
196                            .map_err(crate::helpers::callback_failure);
197                        _ = sender.send((index, result));
198                    }
199                },
200            ))
201            .await;
202        });
203        for _ in 0..results.len() {
204            let (index, value) = receiver
205                .recv()
206                .await
207                .ok_or_else(|| alibi_core::AuthError::internal("Session transformation stopped"))?;
208            let slot = results.get_mut(index).ok_or_else(|| {
209                alibi_core::AuthError::internal("Invalid session transformation row")
210            })?;
211            *slot = Some(value?);
212        }
213        Ok(results.into_iter().flatten().collect())
214    }
215}
216
217// LCOV_EXCL_START
218#[cfg(test)]
219mod tests {
220    use super::*;
221    use crate::test_helpers::{
222        create_auth_request_no_query, create_test_context, create_user_and_session,
223    };
224    use alibi_core::AuthUser;
225    use alibi_core::hooks::with_request_hook_context;
226    use alibi_core::{CreateUser, UpdateUser};
227    use std::sync::Arc;
228    use tokio::sync::Notify;
229    type Schema = alibi_seaorm::store::__private_test_support::bundled_schema::BundledSchema;
230
231    struct Transform {
232        entered: Arc<Notify>,
233        release: Arc<Notify>,
234        finished: Arc<Notify>,
235    }
236    #[async_trait]
237    impl SessionTransform<Schema> for Transform {
238        async fn transform(
239            &self,
240            value: Value,
241            req: &AuthRequest,
242            ctx: &AuthContext<Schema>,
243        ) -> AuthResult<Value> {
244            let id = value
245                .pointer("/user/id")
246                .and_then(Value::as_str)
247                .expect("actual user id");
248            let name = value
249                .pointer("/user/name")
250                .and_then(Value::as_str)
251                .expect("actual user name");
252            if name == "Reject" {
253                self.entered.notified().await;
254                return Err(alibi_core::AuthError::internal("application failure"));
255            }
256            self.entered.notify_one();
257            self.release.notified().await;
258            assert_eq!(req.path(), "/multi-session/list-device-sessions");
259            assert_eq!(
260                alibi_core::hooks::current_request_hook_context()
261                    .expect("retained real request")
262                    .headers
263                    .get("x-application"),
264                Some(&"original".to_owned())
265            );
266            ctx.database
267                .update_user(
268                    id,
269                    UpdateUser {
270                        name: Some("Completed".into()),
271                        ..Default::default()
272                    },
273                )
274                .await?;
275            self.finished.notify_one();
276            Ok(value)
277        }
278    }
279
280    #[tokio::test]
281    async fn rejected_device_list_preserves_launched_callback_request_and_database_ownership() {
282        let ctx = create_test_context().await;
283        let (reject, reject_session) = create_user_and_session(
284            &ctx,
285            CreateUser::new()
286                .with_email("reject@custom.fixture.test")
287                .with_name("Reject"),
288            chrono::Duration::hours(1),
289        )
290        .await;
291        let (slow, slow_session) = create_user_and_session(
292            &ctx,
293            CreateUser::new()
294                .with_email("slow@custom.fixture.test")
295                .with_name("Slow"),
296            chrono::Duration::hours(1),
297        )
298        .await;
299        let entered = Arc::new(Notify::new());
300        let release = Arc::new(Notify::new());
301        let finished = Arc::new(Notify::new());
302        let plugin = CustomSessionPlugin::new(Transform {
303            entered,
304            release: release.clone(),
305            finished: finished.clone(),
306        })
307        .mutate_device_sessions(true);
308        let mut req = create_auth_request_no_query(
309            HttpMethod::Get,
310            "/multi-session/list-device-sessions",
311            Some(&reject_session.token),
312            None,
313        );
314        _ = req
315            .headers
316            .insert("x-application".into(), "original".into());
317        let cookies = [&reject_session.token, &slow_session.token]
318            .into_iter()
319            .map(|token| {
320                format!(
321                    "{}_multi-{}={}",
322                    ctx.config.session.cookie_name,
323                    token.to_lowercase(),
324                    alibi_core::utils::cookie_utils::sign_cookie_value(
325                        token,
326                        ctx.config.current_secret()
327                    )
328                )
329            })
330            .collect::<Vec<_>>();
331        let ordinary = req.header("cookie").expect("signed current cookie").clone();
332        _ = req.headers.insert(
333            "cookie".into(),
334            format!("{ordinary}; {}", cookies.join("; ")),
335        );
336        let response = crate::multi_session::MultiSessionPlugin::new()
337            .on_request(&req, &ctx)
338            .await
339            .expect("real list")
340            .expect("list route");
341        assert_eq!(response.status, 200);
342        let result = tokio::time::timeout(
343            std::time::Duration::from_secs(2),
344            with_request_hook_context(&req, plugin.after_request(&req, &ctx, response)),
345        )
346        .await
347        .expect("aggregate rejects without waiting for held callback");
348        assert!(matches!(
349            result,
350            Err(alibi_core::AuthError::CallbackFailure(_))
351        ));
352        assert_eq!(
353            ctx.database
354                .get_user_by_id(&slow.id)
355                .await
356                .expect("read")
357                .expect("stored slow owner")
358                .name(),
359            Some("Slow")
360        );
361        release.notify_one();
362        tokio::time::timeout(std::time::Duration::from_secs(2), finished.notified())
363            .await
364            .expect("launched callback completes after observer rejection");
365        assert_eq!(
366            ctx.database
367                .get_user_by_id(&slow.id)
368                .await
369                .expect("read")
370                .expect("stored slow owner")
371                .name(),
372            Some("Completed")
373        );
374        assert_eq!(
375            ctx.database
376                .get_user_by_id(&reject.id)
377                .await
378                .expect("read")
379                .expect("stored reject owner")
380                .name(),
381            Some("Reject")
382        );
383    }
384}
385// LCOV_EXCL_STOP