1use 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#[async_trait]
16pub trait SessionTransform<S: AuthSchema>: Send + Sync {
17 async fn transform(
20 &self,
21 session: Value,
22 request: &AuthRequest,
23 context: &AuthContext<S>,
24 ) -> AuthResult<Value>;
25}
26
27pub 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 #[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 metadata.core = false;
80 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 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 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#[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