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(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 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#[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