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::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