1use alibi_core::endpoint::{
3 EndpointCall, EndpointContextPatch, EndpointDefinition, EndpointHook, EndpointInput,
4 EndpointResponse, ServerEndpoint,
5};
6use alibi_core::{
7 AuthContext, AuthError, AuthInitContext, AuthPlugin, AuthRequest, AuthResponse, AuthResult,
8 AuthRoute, AuthSchema, HttpMethod,
9};
10use async_trait::async_trait;
11use serde_json::Value;
12use std::sync::Arc;
13#[derive(Clone)]
15pub enum SessionTransformContext {
16 Http(Box<AuthRequest>),
17 Endpoint(Box<EndpointCall>),
18}
19impl SessionTransformContext {
20 #[must_use]
22 pub fn request(&self) -> Option<&AuthRequest> {
23 match self {
24 Self::Http(request) => Some(request),
25 Self::Endpoint(call) => call.request(),
26 }
27 }
28 #[must_use]
29 pub fn endpoint_call(&self) -> Option<&EndpointCall> {
30 match self {
31 Self::Endpoint(call) => Some(call),
32 Self::Http(_) => None,
33 }
34 }
35 #[must_use]
36 pub fn headers(&self) -> Option<&std::collections::HashMap<String, String>> {
37 match self {
38 Self::Http(request) => Some(&request.headers),
39 Self::Endpoint(call) => call.headers(),
40 }
41 }
42 #[must_use]
43 pub fn header(&self, name: &str) -> Option<&String> {
44 self.headers()?
45 .iter()
46 .find_map(|(key, value)| key.eq_ignore_ascii_case(name).then_some(value))
47 }
48 #[must_use]
49 pub fn path(&self) -> &str {
50 match self {
51 Self::Http(request) => request.path(),
52 Self::Endpoint(call) => call.path().unwrap_or(""),
53 }
54 }
55 #[must_use]
56 pub fn method(&self) -> &HttpMethod {
57 match self {
58 Self::Http(request) => request.method(),
59 Self::Endpoint(call) => call.method().unwrap_or(&HttpMethod::Get),
60 }
61 }
62}
63struct SessionTransformHandle<S: AuthSchema>(Arc<dyn SessionTransform<S>>);
64
65#[async_trait]
71pub trait SessionTransform<S: AuthSchema>: Send + Sync {
72 async fn transform(
75 &self,
76 session: Value,
77 request: &SessionTransformContext,
78 context: &AuthContext<S>,
79 ) -> AuthResult<Value>;
80}
81
82pub struct CustomSessionPlugin<S: AuthSchema> {
86 transform: Arc<dyn SessionTransform<S>>,
87 mutate_device_sessions: bool,
88}
89
90impl<S: AuthSchema> CustomSessionPlugin<S> {
91 #[must_use]
93 pub const fn get_session_endpoint() -> ServerEndpoint<Value> {
94 ServerEndpoint::new("custom-session", "getSession")
95 }
96
97 #[must_use]
98 pub fn new(transform: impl SessionTransform<S> + 'static) -> Self {
99 Self {
100 transform: Arc::new(transform),
101 mutate_device_sessions: false,
102 }
103 }
104
105 #[must_use]
107 pub fn mutate_device_sessions(mut self, enabled: bool) -> Self {
108 self.mutate_device_sessions = enabled;
109 self
110 }
111}
112
113#[async_trait]
114impl<S: AuthSchema> AuthPlugin<S> for CustomSessionPlugin<S> {
115 fn static_openapi_metadata(&self) -> alibi_core::PluginOpenApiMetadata {
116 crate::metadata::plugin_metadata(
117 <Self as alibi_core::AuthPlugin<S>>::name(self),
118 &<Self as alibi_core::AuthPlugin<S>>::routes(self),
119 )
120 }
121
122 async fn on_init(&self, ctx: &mut AuthInitContext<S>) -> AuthResult<()> {
123 ctx.extensions
124 .insert(SessionTransformHandle(self.transform.clone()));
125 Ok(())
126 }
127 fn endpoint_hooks(&self) -> Vec<&dyn EndpointHook<S>> {
128 vec![self]
129 }
130 fn server_endpoints(&self) -> Vec<EndpointDefinition> {
131 vec![crate::endpoint::definition(
132 "getSession",
133 "get_session",
134 Some("/get-session"),
135 HttpMethod::Get,
136 )]
137 }
138 fn validate_endpoint(
139 &self,
140 call: &EndpointCall,
141 ctx: &AuthContext<S>,
142 ) -> AuthResult<EndpointInput> {
143 <super::session_management::SessionManagementPlugin as AuthPlugin<S>>::validate_endpoint(
144 &super::session_management::SessionManagementPlugin::new(),
145 call,
146 ctx,
147 )
148 }
149 async fn on_endpoint(
150 &self,
151 call: &EndpointCall,
152 ctx: &AuthContext<S>,
153 ) -> AuthResult<EndpointResponse> {
154 project_endpoint(call, ctx).await
155 }
156 fn name(&self) -> &'static str {
157 "custom-session"
158 }
159 fn routes(&self) -> Vec<AuthRoute> {
160 vec![
161 AuthRoute::get("/get-session", "get_session"),
162 AuthRoute::post("/get-session", "custom_session_post"),
163 ]
164 }
165
166 fn openapi_metadata(
167 &self,
168 ctx: &alibi_core::AuthInitContext<S>,
169 ) -> alibi_core::openapi::PluginOpenApiMetadata {
170 let mut metadata =
171 crate::metadata::instance_plugin_metadata("session-management", &self.routes(), ctx);
172 metadata.core = false;
174 for (_, _, endpoint) in &mut metadata.endpoints {
177 endpoint.native_extension = true;
178 }
179 metadata
180 }
181
182 async fn on_request(
183 &self,
184 req: &AuthRequest,
185 ctx: &AuthContext<S>,
186 ) -> AuthResult<Option<AuthResponse>> {
187 if req.path() != "/get-session" {
188 return Ok(None);
189 }
190 if req.method() == &HttpMethod::Post {
191 return Ok(Some(AuthResponse::new(404)));
192 }
193 if req.method() != &HttpMethod::Get {
194 return Ok(None);
195 }
196 Ok(Some(project_http(req, ctx).await?))
197 }
198
199 async fn after_request(
200 &self,
201 req: &AuthRequest,
202 ctx: &AuthContext<S>,
203 mut response: AuthResponse,
204 ) -> AuthResult<AuthResponse> {
205 if !self.mutate_device_sessions
206 || req.path() != "/multi-session/list-device-sessions"
207 || response.status >= 400
208 {
209 return Ok(response);
210 }
211 let value: Value = serde_json::from_slice(&response.body)?;
212 if let Value::Array(sessions) = value {
213 let transformed = self
214 .transform_list(
215 sessions,
216 &SessionTransformContext::Http(Box::new(req.clone())),
217 ctx,
218 )
219 .await?;
220 response.body = serde_json::to_vec(&transformed)?;
221 }
222 Ok(response)
223 }
224}
225
226#[async_trait]
227impl<S: AuthSchema> EndpointHook<S> for CustomSessionPlugin<S> {
228 fn matches_after(
229 &self,
230 call: &EndpointCall,
231 _ctx: &AuthContext<S>,
232 _response: &EndpointResponse,
233 ) -> AuthResult<bool> {
234 Ok(self.mutate_device_sessions
235 && call.path() == Some("/multi-session/list-device-sessions"))
236 }
237 async fn after(
238 &self,
239 call: &EndpointCall,
240 ctx: &AuthContext<S>,
241 mut response: EndpointResponse,
242 ) -> AuthResult<EndpointResponse> {
243 if let Ok(alibi_core::utils::json::JsValue::Array(sessions)) = response.result() {
244 let sessions = sessions
245 .iter()
246 .map(alibi_core::utils::json::JsValue::to_json_value)
247 .collect::<Result<Vec<_>, _>>()?;
248 let transformed = self
249 .transform_list(
250 sessions,
251 &SessionTransformContext::Endpoint(Box::new(call.clone())),
252 ctx,
253 )
254 .await?;
255 response.replace(Value::Array(transformed).into());
256 }
257 Ok(response)
258 }
259}
260
261impl<S: AuthSchema> CustomSessionPlugin<S> {
262 async fn transform_list(
263 &self,
264 sessions: Vec<Value>,
265 request: &SessionTransformContext,
266 context: &AuthContext<S>,
267 ) -> AuthResult<Vec<Value>> {
268 if sessions.is_empty() {
269 return Ok(Vec::new());
270 }
271 let mut results = vec![None; sessions.len()];
272 let transform = self.transform.clone();
273 let request = request.clone();
274 let context = AuthContext::<S> {
275 config: context.config.clone(),
276 database: context.database.clone(),
277 email_provider: context.email_provider.clone(),
278 metadata: context.metadata.clone(),
279 extensions: context.extensions.clone(),
280 };
281 let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
282 crate::helpers::spawn_in_request_context(async move {
285 _ = futures_util::future::join_all(sessions.into_iter().enumerate().map(
286 |(index, session)| {
287 let transform = &transform;
288 let request = &request;
289 let context = &context;
290 let sender = &sender;
291 async move {
292 let result = transform
293 .transform(session, request, context)
294 .await
295 .map_err(crate::helpers::callback_failure);
296 _ = sender.send((index, result));
297 }
298 },
299 ))
300 .await;
301 });
302 for _ in 0..results.len() {
303 let (index, value) = receiver
304 .recv()
305 .await
306 .ok_or_else(|| alibi_core::AuthError::internal("Session transformation stopped"))?;
307 let slot = results.get_mut(index).ok_or_else(|| {
308 alibi_core::AuthError::internal("Invalid session transformation row")
309 })?;
310 *slot = Some(value?);
311 }
312 Ok(results.into_iter().flatten().collect())
313 }
314}
315
316pub(crate) fn configured<S: AuthSchema>(ctx: &AuthContext<S>) -> bool {
317 ctx.extensions.get::<SessionTransformHandle<S>>().is_some()
318}
319pub(crate) async fn project_http<S: AuthSchema>(
320 req: &AuthRequest,
321 ctx: &AuthContext<S>,
322) -> AuthResult<AuthResponse> {
323 if req.method() != &HttpMethod::Get {
324 return Ok(AuthResponse::new(404));
325 }
326 let transform = ctx
327 .extensions
328 .get::<SessionTransformHandle<S>>()
329 .ok_or_else(|| AuthError::internal("Custom session projection is not installed"))?;
330 let transform = &transform.0;
331 let prior_headers = req.take_response_headers();
332 let mut response = match super::session_management::SessionManagementPlugin::new()
333 .handle_base_get_session(req, ctx)
334 .await
335 {
336 Ok(response) => response,
337 Err(_) => AuthResponse::json(200, &Value::Null)?,
338 };
339 let session: Value = if response.status >= 400 {
340 Value::Null
341 } else {
342 serde_json::from_slice(&response.body)?
343 };
344 let core_headers = req.take_response_headers();
345 for (name, value) in prior_headers {
346 req.queue_response_header(name, value);
347 }
348 let (issued_cookies, _) =
349 alibi_core::session::cookie_cache::runtime::take_issuance(req.extensions());
350 if session.is_null() {
351 return Ok(AuthResponse::json(200, &Value::Null)?);
354 }
355 let transformed = transform
356 .transform(
357 session,
358 &SessionTransformContext::Http(Box::new(req.clone())),
359 ctx,
360 )
361 .await
362 .map_err(crate::helpers::callback_failure)?;
363 response.body = serde_json::to_vec(&transformed)?;
364 for (name, value) in core_headers {
365 response.headers.append(name, value);
366 }
367 for cookie in issued_cookies {
368 response.headers.append("Set-Cookie", cookie);
369 }
370 Ok(response)
371}
372pub(crate) async fn project_endpoint<S: AuthSchema>(
373 call: &EndpointCall,
374 ctx: &AuthContext<S>,
375) -> AuthResult<EndpointResponse> {
376 let transform = ctx
377 .extensions
378 .get::<SessionTransformHandle<S>>()
379 .ok_or_else(|| AuthError::internal("Custom session projection is not installed"))?;
380 let prior = call.take_response_headers();
381 let mut read = call.clone();
382 EndpointContextPatch {
383 method: Some(HttpMethod::Get),
384 ..Default::default()
385 }
386 .apply(&mut read);
387 let session = super::session_management::SessionManagementPlugin::new()
388 .get_session_response(&read, ctx)
389 .await
390 .ok()
391 .flatten();
392 let core_headers = read.take_response_headers();
393 for (name, value) in prior {
394 call.queue_response_header(name, value);
395 }
396 let (cookies, _) = alibi_core::session::cookie_cache::runtime::take_issuance(read.extensions());
397 let Some(session) = session else {
398 return EndpointResponse::json(&Value::Null);
399 };
400 let value = transform
401 .0
402 .transform(
403 serde_json::to_value(session)?,
404 &SessionTransformContext::Endpoint(Box::new(call.clone())),
405 ctx,
406 )
407 .await
408 .map_err(crate::helpers::callback_failure)?;
409 let mut response = EndpointResponse::json(&value)?
410 .with_header("cache-control", "no-store")
411 .with_header("pragma", "no-cache");
412 response.merge_headers(core_headers);
413 for cookie in cookies {
414 response = response.with_header("set-cookie", cookie);
415 }
416 Ok(response)
417}
418
419#[cfg(test)]
421mod tests {
422 use super::*;
423 use crate::test_helpers::{
424 create_auth_request_no_query, create_test_context, create_user_and_session,
425 };
426 use alibi_core::AuthUser;
427 use alibi_core::hooks::with_request_hook_context;
428 use alibi_core::{CreateUser, UpdateUser};
429 use std::sync::Arc;
430 use tokio::sync::Notify;
431 type Schema = alibi_seaorm::store::__private_test_support::bundled_schema::BundledSchema;
432
433 struct Transform {
434 entered: Arc<Notify>,
435 release: Arc<Notify>,
436 finished: Arc<Notify>,
437 }
438 #[async_trait]
439 impl SessionTransform<Schema> for Transform {
440 async fn transform(
441 &self,
442 value: Value,
443 req: &SessionTransformContext,
444 ctx: &AuthContext<Schema>,
445 ) -> AuthResult<Value> {
446 let id = value
447 .pointer("/user/id")
448 .and_then(Value::as_str)
449 .expect("actual user id");
450 let name = value
451 .pointer("/user/name")
452 .and_then(Value::as_str)
453 .expect("actual user name");
454 if name == "Reject" {
455 self.entered.notified().await;
456 return Err(alibi_core::AuthError::internal("application failure"));
457 }
458 self.entered.notify_one();
459 self.release.notified().await;
460 assert_eq!(req.path(), "/multi-session/list-device-sessions");
461 assert_eq!(
462 alibi_core::hooks::current_request_hook_context()
463 .expect("retained real request")
464 .headers
465 .get("x-application"),
466 Some(&"original".to_owned())
467 );
468 ctx.database
469 .update_user(
470 id,
471 UpdateUser {
472 name: Some("Completed".into()),
473 ..Default::default()
474 },
475 )
476 .await?;
477 self.finished.notify_one();
478 Ok(value)
479 }
480 }
481
482 #[tokio::test]
483 async fn rejected_device_list_preserves_launched_callback_request_and_database_ownership() {
484 let ctx = create_test_context().await;
485 let (reject, reject_session) = create_user_and_session(
486 &ctx,
487 CreateUser::new()
488 .with_email("reject@custom.fixture.test")
489 .with_name("Reject"),
490 chrono::Duration::hours(1),
491 )
492 .await;
493 let (slow, slow_session) = create_user_and_session(
494 &ctx,
495 CreateUser::new()
496 .with_email("slow@custom.fixture.test")
497 .with_name("Slow"),
498 chrono::Duration::hours(1),
499 )
500 .await;
501 let entered = Arc::new(Notify::new());
502 let release = Arc::new(Notify::new());
503 let finished = Arc::new(Notify::new());
504 let plugin = CustomSessionPlugin::new(Transform {
505 entered,
506 release: release.clone(),
507 finished: finished.clone(),
508 })
509 .mutate_device_sessions(true);
510 let mut req = create_auth_request_no_query(
511 HttpMethod::Get,
512 "/multi-session/list-device-sessions",
513 Some(&reject_session.token),
514 None,
515 );
516 _ = req
517 .headers
518 .insert("x-application".into(), "original".into());
519 let cookies = [&reject_session.token, &slow_session.token]
520 .into_iter()
521 .map(|token| {
522 format!(
523 "{}_multi-{}={}",
524 ctx.config.session.cookie_name,
525 token.to_lowercase(),
526 alibi_core::utils::cookie_utils::sign_cookie_value(
527 token,
528 ctx.config.current_secret()
529 )
530 )
531 })
532 .collect::<Vec<_>>();
533 let ordinary = req.header("cookie").expect("signed current cookie").clone();
534 _ = req.headers.insert(
535 "cookie".into(),
536 format!("{ordinary}; {}", cookies.join("; ")),
537 );
538 let response = crate::multi_session::MultiSessionPlugin::new()
539 .on_request(&req, &ctx)
540 .await
541 .expect("real list")
542 .expect("list route");
543 assert_eq!(response.status, 200);
544 let result = tokio::time::timeout(
545 std::time::Duration::from_secs(2),
546 with_request_hook_context(&req, plugin.after_request(&req, &ctx, response)),
547 )
548 .await
549 .expect("aggregate rejects without waiting for held callback");
550 assert!(matches!(
551 result,
552 Err(alibi_core::AuthError::CallbackFailure(_))
553 ));
554 assert_eq!(
555 ctx.database
556 .get_user_by_id(&slow.id)
557 .await
558 .expect("read")
559 .expect("stored slow owner")
560 .name(),
561 Some("Slow")
562 );
563 release.notify_one();
564 tokio::time::timeout(std::time::Duration::from_secs(2), finished.notified())
565 .await
566 .expect("launched callback completes after observer rejection");
567 assert_eq!(
568 ctx.database
569 .get_user_by_id(&slow.id)
570 .await
571 .expect("read")
572 .expect("stored slow owner")
573 .name(),
574 Some("Completed")
575 );
576 assert_eq!(
577 ctx.database
578 .get_user_by_id(&reject.id)
579 .await
580 .expect("read")
581 .expect("stored reject owner")
582 .name(),
583 Some("Reject")
584 );
585 }
586}
587