Skip to main content

alibi_api/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(callback_error)?;
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 endpoint = alibi_core::endpoint::current_endpoint_call_context();
183        let hook = alibi_core::hooks::current_request_hook_context();
184        let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
185        // Launched callbacks retain ownership after the aggregate rejects,
186        // matching the independent application work of a device-session list.
187        drop(tokio::spawn(async move {
188            let project = async {
189                drop(
190                    futures_util::future::join_all(sessions.into_iter().enumerate().map(
191                        |(index, session)| {
192                            let transform = &transform;
193                            let request = &request;
194                            let context = &context;
195                            let sender = &sender;
196                            async move {
197                                let result = transform
198                                    .transform(session, request, context)
199                                    .await
200                                    .map_err(callback_error);
201                                let _closed = sender.send((index, result));
202                            }
203                        },
204                    ))
205                    .await,
206                );
207            };
208            if let Some(endpoint) = endpoint {
209                alibi_core::endpoint::with_endpoint_call_context(
210                    endpoint,
211                    alibi_core::hooks::with_optional_request_hook_context(hook, project),
212                )
213                .await;
214            } else {
215                alibi_core::hooks::with_optional_request_hook_context(hook, project).await;
216            }
217        }));
218        for _ in 0..results.len() {
219            let (index, value) = receiver
220                .recv()
221                .await
222                .ok_or_else(|| alibi_core::AuthError::internal("Session transformation stopped"))?;
223            let slot = results.get_mut(index).ok_or_else(|| {
224                alibi_core::AuthError::internal("Invalid session transformation row")
225            })?;
226            *slot = Some(value?);
227        }
228        Ok(results.into_iter().flatten().collect())
229    }
230}
231
232fn callback_error(error: alibi_core::AuthError) -> alibi_core::AuthError {
233    use alibi_core::AuthError;
234    match error {
235        AuthError::Api { .. } | AuthError::Upstream { .. } | AuthError::CallbackFailure(_) => error,
236        error => AuthError::CallbackFailure(Box::new(error)),
237    }
238}
239
240// LCOV_EXCL_START
241#[cfg(test)]
242mod tests {
243    use super::*;
244    use crate::plugins::test_helpers::{
245        create_auth_request_no_query, create_test_context, create_user_and_session,
246    };
247    use alibi_core::hooks::with_request_hook_context;
248    use alibi_core::{CreateUser, UpdateUser};
249    use std::sync::Arc;
250    use tokio::sync::Notify;
251    type Schema = alibi_seaorm::store::__private_test_support::bundled_schema::BundledSchema;
252
253    struct Transform {
254        entered: Arc<Notify>,
255        release: Arc<Notify>,
256        finished: Arc<Notify>,
257    }
258    #[async_trait]
259    impl SessionTransform<Schema> for Transform {
260        async fn transform(
261            &self,
262            value: Value,
263            req: &AuthRequest,
264            ctx: &AuthContext<Schema>,
265        ) -> AuthResult<Value> {
266            let id = value
267                .pointer("/user/id")
268                .and_then(Value::as_str)
269                .expect("actual user id");
270            let name = value
271                .pointer("/user/name")
272                .and_then(Value::as_str)
273                .expect("actual user name");
274            if name == "Reject" {
275                self.entered.notified().await;
276                return Err(alibi_core::AuthError::internal("application failure"));
277            }
278            self.entered.notify_one();
279            self.release.notified().await;
280            assert_eq!(req.path(), "/multi-session/list-device-sessions");
281            assert_eq!(
282                alibi_core::hooks::current_request_hook_context()
283                    .expect("retained real request")
284                    .headers
285                    .get("x-application"),
286                Some(&"original".to_owned())
287            );
288            ctx.database
289                .update_user(
290                    id,
291                    UpdateUser {
292                        name: Some("Completed".into()),
293                        ..Default::default()
294                    },
295                )
296                .await?;
297            self.finished.notify_one();
298            Ok(value)
299        }
300    }
301
302    #[tokio::test]
303    async fn rejected_device_list_preserves_launched_callback_request_and_database_ownership() {
304        let ctx = create_test_context().await;
305        let (reject, reject_session) = create_user_and_session(
306            &ctx,
307            CreateUser::new()
308                .with_email("reject@custom.fixture.test")
309                .with_name("Reject"),
310            chrono::Duration::hours(1),
311        )
312        .await;
313        let (slow, slow_session) = create_user_and_session(
314            &ctx,
315            CreateUser::new()
316                .with_email("slow@custom.fixture.test")
317                .with_name("Slow"),
318            chrono::Duration::hours(1),
319        )
320        .await;
321        let entered = Arc::new(Notify::new());
322        let release = Arc::new(Notify::new());
323        let finished = Arc::new(Notify::new());
324        let plugin = CustomSessionPlugin::new(Transform {
325            entered,
326            release: release.clone(),
327            finished: finished.clone(),
328        })
329        .mutate_device_sessions(true);
330        let mut req = create_auth_request_no_query(
331            HttpMethod::Get,
332            "/multi-session/list-device-sessions",
333            Some(&reject_session.token),
334            None,
335        );
336        drop(
337            req.headers
338                .insert("x-application".into(), "original".into()),
339        );
340        use alibi_core::utils::cookie_utils::sign_cookie_value;
341        let cookies = [&reject_session.token, &slow_session.token]
342            .into_iter()
343            .map(|token| {
344                format!(
345                    "{}_multi-{}={}",
346                    ctx.config.session.cookie_name,
347                    token.to_lowercase(),
348                    sign_cookie_value(token, ctx.config.current_secret())
349                )
350            })
351            .collect::<Vec<_>>();
352        let ordinary = req.header("cookie").expect("signed current cookie").clone();
353        drop(req.headers.insert(
354            "cookie".into(),
355            format!("{ordinary}; {}", cookies.join("; ")),
356        ));
357        let response = super::super::multi_session::MultiSessionPlugin::new()
358            .on_request(&req, &ctx)
359            .await
360            .expect("real list")
361            .expect("list route");
362        assert_eq!(response.status, 200);
363        let result = tokio::time::timeout(
364            std::time::Duration::from_secs(2),
365            with_request_hook_context(&req, plugin.after_request(&req, &ctx, response)),
366        )
367        .await
368        .expect("aggregate rejects without waiting for held callback");
369        assert!(matches!(
370            result,
371            Err(alibi_core::AuthError::CallbackFailure(_))
372        ));
373        assert_eq!(
374            ctx.database
375                .get_user_by_id(&slow.id)
376                .await
377                .expect("read")
378                .expect("stored slow owner")
379                .name
380                .as_deref(),
381            Some("Slow")
382        );
383        release.notify_one();
384        tokio::time::timeout(std::time::Duration::from_secs(2), finished.notified())
385            .await
386            .expect("launched callback completes after observer rejection");
387        assert_eq!(
388            ctx.database
389                .get_user_by_id(&slow.id)
390                .await
391                .expect("read")
392                .expect("stored slow owner")
393                .name
394                .as_deref(),
395            Some("Completed")
396        );
397        assert_eq!(
398            ctx.database
399                .get_user_by_id(&reject.id)
400                .await
401                .expect("read")
402                .expect("stored reject owner")
403                .name
404                .as_deref(),
405            Some("Reject")
406        );
407    }
408}
409// LCOV_EXCL_STOP