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::hooks::with_request_hook_context;
225    use alibi_core::{CreateUser, UpdateUser};
226    use std::sync::Arc;
227    use tokio::sync::Notify;
228    type Schema = alibi_seaorm::store::__private_test_support::bundled_schema::BundledSchema;
229
230    struct Transform {
231        entered: Arc<Notify>,
232        release: Arc<Notify>,
233        finished: Arc<Notify>,
234    }
235    #[async_trait]
236    impl SessionTransform<Schema> for Transform {
237        async fn transform(
238            &self,
239            value: Value,
240            req: &AuthRequest,
241            ctx: &AuthContext<Schema>,
242        ) -> AuthResult<Value> {
243            let id = value
244                .pointer("/user/id")
245                .and_then(Value::as_str)
246                .expect("actual user id");
247            let name = value
248                .pointer("/user/name")
249                .and_then(Value::as_str)
250                .expect("actual user name");
251            if name == "Reject" {
252                self.entered.notified().await;
253                return Err(alibi_core::AuthError::internal("application failure"));
254            }
255            self.entered.notify_one();
256            self.release.notified().await;
257            assert_eq!(req.path(), "/multi-session/list-device-sessions");
258            assert_eq!(
259                alibi_core::hooks::current_request_hook_context()
260                    .expect("retained real request")
261                    .headers
262                    .get("x-application"),
263                Some(&"original".to_owned())
264            );
265            ctx.database
266                .update_user(
267                    id,
268                    UpdateUser {
269                        name: Some("Completed".into()),
270                        ..Default::default()
271                    },
272                )
273                .await?;
274            self.finished.notify_one();
275            Ok(value)
276        }
277    }
278
279    #[tokio::test]
280    async fn rejected_device_list_preserves_launched_callback_request_and_database_ownership() {
281        let ctx = create_test_context().await;
282        let (reject, reject_session) = create_user_and_session(
283            &ctx,
284            CreateUser::new()
285                .with_email("reject@custom.fixture.test")
286                .with_name("Reject"),
287            chrono::Duration::hours(1),
288        )
289        .await;
290        let (slow, slow_session) = create_user_and_session(
291            &ctx,
292            CreateUser::new()
293                .with_email("slow@custom.fixture.test")
294                .with_name("Slow"),
295            chrono::Duration::hours(1),
296        )
297        .await;
298        let entered = Arc::new(Notify::new());
299        let release = Arc::new(Notify::new());
300        let finished = Arc::new(Notify::new());
301        let plugin = CustomSessionPlugin::new(Transform {
302            entered,
303            release: release.clone(),
304            finished: finished.clone(),
305        })
306        .mutate_device_sessions(true);
307        let mut req = create_auth_request_no_query(
308            HttpMethod::Get,
309            "/multi-session/list-device-sessions",
310            Some(&reject_session.token),
311            None,
312        );
313        _ = req
314            .headers
315            .insert("x-application".into(), "original".into());
316        let cookies = [&reject_session.token, &slow_session.token]
317            .into_iter()
318            .map(|token| {
319                format!(
320                    "{}_multi-{}={}",
321                    ctx.config.session.cookie_name,
322                    token.to_lowercase(),
323                    alibi_core::utils::cookie_utils::sign_cookie_value(
324                        token,
325                        ctx.config.current_secret()
326                    )
327                )
328            })
329            .collect::<Vec<_>>();
330        let ordinary = req.header("cookie").expect("signed current cookie").clone();
331        _ = req.headers.insert(
332            "cookie".into(),
333            format!("{ordinary}; {}", cookies.join("; ")),
334        );
335        let response = crate::multi_session::MultiSessionPlugin::new()
336            .on_request(&req, &ctx)
337            .await
338            .expect("real list")
339            .expect("list route");
340        assert_eq!(response.status, 200);
341        let result = tokio::time::timeout(
342            std::time::Duration::from_secs(2),
343            with_request_hook_context(&req, plugin.after_request(&req, &ctx, response)),
344        )
345        .await
346        .expect("aggregate rejects without waiting for held callback");
347        assert!(matches!(
348            result,
349            Err(alibi_core::AuthError::CallbackFailure(_))
350        ));
351        assert_eq!(
352            ctx.database
353                .get_user_by_id(&slow.id)
354                .await
355                .expect("read")
356                .expect("stored slow owner")
357                .name
358                .as_deref(),
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                .as_deref(),
373            Some("Completed")
374        );
375        assert_eq!(
376            ctx.database
377                .get_user_by_id(&reject.id)
378                .await
379                .expect("read")
380                .expect("stored reject owner")
381                .name
382                .as_deref(),
383            Some("Reject")
384        );
385    }
386}
387// LCOV_EXCL_STOP