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 = super::session_management::SessionManagementPlugin::new()
333 .handle_base_get_session(req, ctx)
334 .await?;
335 let core_headers = req.take_response_headers();
336 for (name, value) in prior_headers {
337 req.queue_response_header(name, value);
338 }
339 let (issued_cookies, _) =
340 alibi_core::session::cookie_cache::runtime::take_issuance(req.extensions());
341 if response.status >= 400 {
342 let mut failure = AuthResponse::new(response.status);
345 failure.body = response.body;
346 if let Some(content_type) = response.headers.get("content-type") {
347 failure = failure.with_header("content-type", content_type.to_owned());
348 }
349 return Ok(failure);
350 }
351 let session: Value = serde_json::from_slice(&response.body)?;
352 if !session.is_null() {
354 let transformed = transform
355 .transform(
356 session,
357 &SessionTransformContext::Http(Box::new(req.clone())),
358 ctx,
359 )
360 .await
361 .map_err(crate::helpers::callback_failure)?;
362 response.body = serde_json::to_vec(&transformed)?;
363 }
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 let core_headers = read.take_response_headers();
391 for (name, value) in prior {
392 call.queue_response_header(name, value);
393 }
394 let (cookies, _) = alibi_core::session::cookie_cache::runtime::take_issuance(read.extensions());
395 let value = match session {
397 Some(session) => transform
398 .0
399 .transform(
400 serde_json::to_value(session)?,
401 &SessionTransformContext::Endpoint(Box::new(call.clone())),
402 ctx,
403 )
404 .await
405 .map_err(crate::helpers::callback_failure)?,
406 None => Value::Null,
407 };
408 let mut response = EndpointResponse::json(&value)?
409 .with_header("cache-control", "no-store")
410 .with_header("pragma", "no-cache");
411 response.merge_headers(core_headers);
412 for cookie in cookies {
413 response = response.with_header("set-cookie", cookie);
414 }
415 Ok(response)
416}
417
418#[cfg(test)]
420mod tests {
421 use super::*;
422 use crate::test_helpers::{
423 create_auth_request_no_query, create_test_context, create_user_and_session,
424 };
425 use alibi_core::AuthUser;
426 use alibi_core::hooks::with_request_hook_context;
427 use alibi_core::{CreateUser, UpdateUser};
428 use std::sync::Arc;
429 use tokio::sync::Notify;
430 type Schema = alibi_seaorm::store::__private_test_support::bundled_schema::BundledSchema;
431
432 struct Transform {
433 entered: Arc<Notify>,
434 release: Arc<Notify>,
435 finished: Arc<Notify>,
436 }
437 #[async_trait]
438 impl SessionTransform<Schema> for Transform {
439 async fn transform(
440 &self,
441 value: Value,
442 req: &SessionTransformContext,
443 ctx: &AuthContext<Schema>,
444 ) -> AuthResult<Value> {
445 let id = value
446 .pointer("/user/id")
447 .and_then(Value::as_str)
448 .expect("actual user id");
449 let name = value
450 .pointer("/user/name")
451 .and_then(Value::as_str)
452 .expect("actual user name");
453 if name == "Reject" {
454 self.entered.notified().await;
455 return Err(alibi_core::AuthError::internal("application failure"));
456 }
457 self.entered.notify_one();
458 self.release.notified().await;
459 assert_eq!(req.path(), "/multi-session/list-device-sessions");
460 assert_eq!(
461 alibi_core::hooks::current_request_hook_context()
462 .expect("retained real request")
463 .headers
464 .get("x-application"),
465 Some(&"original".to_owned())
466 );
467 ctx.database
468 .update_user(
469 id,
470 UpdateUser {
471 name: Some("Completed".into()),
472 ..Default::default()
473 },
474 )
475 .await?;
476 self.finished.notify_one();
477 Ok(value)
478 }
479 }
480
481 #[tokio::test]
482 async fn rejected_device_list_preserves_launched_callback_request_and_database_ownership() {
483 let ctx = create_test_context().await;
484 let (reject, reject_session) = create_user_and_session(
485 &ctx,
486 CreateUser::new()
487 .with_email("reject@custom.fixture.test")
488 .with_name("Reject"),
489 chrono::Duration::hours(1),
490 )
491 .await;
492 let (slow, slow_session) = create_user_and_session(
493 &ctx,
494 CreateUser::new()
495 .with_email("slow@custom.fixture.test")
496 .with_name("Slow"),
497 chrono::Duration::hours(1),
498 )
499 .await;
500 let entered = Arc::new(Notify::new());
501 let release = Arc::new(Notify::new());
502 let finished = Arc::new(Notify::new());
503 let plugin = CustomSessionPlugin::new(Transform {
504 entered,
505 release: release.clone(),
506 finished: finished.clone(),
507 })
508 .mutate_device_sessions(true);
509 let mut req = create_auth_request_no_query(
510 HttpMethod::Get,
511 "/multi-session/list-device-sessions",
512 Some(&reject_session.token),
513 None,
514 );
515 _ = req
516 .headers
517 .insert("x-application".into(), "original".into());
518 let cookies = [&reject_session.token, &slow_session.token]
519 .into_iter()
520 .map(|token| {
521 format!(
522 "{}_multi-{}={}",
523 ctx.config.session.cookie_name,
524 token.to_lowercase(),
525 alibi_core::utils::cookie_utils::sign_cookie_value(
526 token,
527 ctx.config.current_secret()
528 )
529 )
530 })
531 .collect::<Vec<_>>();
532 let ordinary = req.header("cookie").expect("signed current cookie").clone();
533 _ = req.headers.insert(
534 "cookie".into(),
535 format!("{ordinary}; {}", cookies.join("; ")),
536 );
537 let response = crate::multi_session::MultiSessionPlugin::new()
538 .on_request(&req, &ctx)
539 .await
540 .expect("real list")
541 .expect("list route");
542 assert_eq!(response.status, 200);
543 let result = tokio::time::timeout(
544 std::time::Duration::from_secs(2),
545 with_request_hook_context(&req, plugin.after_request(&req, &ctx, response)),
546 )
547 .await
548 .expect("aggregate rejects without waiting for held callback");
549 assert!(matches!(
550 result,
551 Err(alibi_core::AuthError::CallbackFailure(_))
552 ));
553 assert_eq!(
554 ctx.database
555 .get_user_by_id(&slow.id)
556 .await
557 .expect("read")
558 .expect("stored slow owner")
559 .name(),
560 Some("Slow")
561 );
562 release.notify_one();
563 tokio::time::timeout(std::time::Duration::from_secs(2), finished.notified())
564 .await
565 .expect("launched callback completes after observer rejection");
566 assert_eq!(
567 ctx.database
568 .get_user_by_id(&slow.id)
569 .await
570 .expect("read")
571 .expect("stored slow owner")
572 .name(),
573 Some("Completed")
574 );
575 assert_eq!(
576 ctx.database
577 .get_user_by_id(&reject.id)
578 .await
579 .expect("read")
580 .expect("stored reject owner")
581 .name(),
582 Some("Reject")
583 );
584 }
585}
586