Skip to main content

pubhubs/servers/auths/
yivi.rs

1//! Implementation of the `/yivi/...` endpoints
2use super::server::*;
3
4use crate::api;
5use crate::id;
6use crate::misc;
7use crate::misc::jwt;
8use crate::misc::stream_ext::StreamExt as _;
9use crate::servers::yivi;
10
11use super::server::YiviCtx;
12
13use std::collections::{HashMap, VecDeque};
14use std::rc::Rc;
15
16use actix_web::web;
17use futures::future::FutureExt as _;
18use futures::stream::StreamExt as _;
19
20impl App {
21    pub fn chained_sessions_ctl_or_bad_request(&self) -> api::Result<&ChainedSessionsCtl> {
22        self.chained_sessions_ctl.as_ref().ok_or_else(|| {
23            log::debug!("chained sessions control requested, but not available");
24            api::ErrorCode::BadRequest
25        })
26    }
27
28    /// Implements the [`api::auths::YiviWaitForResultEP`] endpoint.
29    pub async fn handle_yivi_wait_for_result(
30        app: Rc<Self>,
31        req: web::Json<api::auths::YiviWaitForResultReq>,
32    ) -> api::Result<api::auths::YiviWaitForResultResp> {
33        let csc = app.chained_sessions_ctl_or_bad_request()?;
34
35        let api::auths::YiviWaitForResultReq { state } = req.into_inner();
36
37        let Some(state) = AuthState::unseal(&state, &app.auth_state_secret) else {
38            return Ok(api::auths::YiviWaitForResultResp::PleaseRestartAuth);
39        };
40
41        let Some(ChainedSessionSetup { id: session_id, .. }) = state.yivi_chained_session else {
42            log::debug!(
43                "yivi-wait-for-result endpoint called on a authentication session without a yivi chained session"
44            );
45            return Err(api::ErrorCode::BadRequest);
46        };
47
48        csc.wait_for_result(session_id).await
49    }
50
51    /// Implements the [`api::auths::YIVI_NEXT_SESSION_PATH`] endpoint.
52    pub async fn handle_yivi_next_session(
53        app: web::Data<std::rc::Rc<App>>,
54        query: web::Query<api::auths::YiviNextSessionQuery>,
55        result_jwt: String,
56    ) -> impl actix_web::Responder {
57        use actix_web::Either::{Left, Right};
58
59        let app = app.into_inner();
60        let api::auths::YiviNextSessionQuery { state } = query.into_inner();
61
62        log::trace!(
63            "yivi server (or imposter) submits next sessions request; jwt: {result_jwt:?}; auth state: {}",
64            state
65        );
66
67        let result_jwt = jwt::JWT::from(result_jwt);
68
69        let Some(state) = AuthState::unseal(&state, &app.auth_state_secret) else {
70            log::debug!(
71                "yivi server (or an imposter) submitted invalid (or expired) auth state to next-session endpoint"
72            );
73            return Left(actix_web::HttpResponse::BadRequest().finish());
74        };
75
76        let Some(ChainedSessionSetup {
77            id: chained_session_id,
78            drip,
79        }) = state.yivi_chained_session
80        else {
81            log::warn!(
82                "yivi server submitted auth state to next-session endpoint without chained session id"
83            );
84            return Left(actix_web::HttpResponse::BadRequest().finish());
85        };
86
87        // NOTE: ChainedSessionsCtl is cheaply cloneable
88        let Some(csc) = app.chained_sessions_ctl.clone() else {
89            log::warn!("next-session endpoint invoked, but chained sessions are not supported");
90            return Left(actix_web::HttpResponse::BadRequest().finish());
91        };
92
93        let Some(yivi) = app.yivi.as_ref() else {
94            log::warn!("next-session endpoint invoked, but yivi is not supported");
95            return Left(actix_web::HttpResponse::BadRequest().finish());
96        };
97
98        let Ok(..) = yivi::SessionResult::open_signed(&result_jwt, &yivi.server_creds) else {
99            log::debug!(
100                "invalid yivi signed session result submitted by yivi server (or imposter)",
101            );
102            log::trace!("invalid signed session result jwt: {result_jwt}");
103            return Left(actix_web::HttpResponse::BadRequest().finish());
104        };
105
106        log::trace!(
107            "yivi server submitted disclosure and is waiting for chained session {chained_session_id}"
108        );
109
110        let request_id = id::Id::random();
111
112        let wfns_fut =
113            csc.clone()
114                .wait_for_next_session(chained_session_id, request_id, result_jwt);
115
116        if drip {
117            // When wfns_fut is dropped (because actix has detected the yivi server has
118            // disconnected), we want to abort the WaitForNextSession via AbortWaitForNextSession.
119            let on_drop = move || {
120                tokio::task::spawn_local(async move {
121                    let _ = csc
122                        .send_command(CscCommand::AbortWaitForNextSession {
123                            chained_session_id,
124                            request_id,
125                        })
126                        .await;
127                });
128            };
129
130            let wfns_fut = async move {
131                let _deferred = misc::defer(on_drop);
132                wfns_fut.await
133            };
134
135            Right(Left(Self::dripping_wfns_responder(wfns_fut)))
136        } else {
137            Right(Right(Self::regular_wfns_responder(wfns_fut).await))
138        }
139    }
140
141    async fn regular_wfns_responder(
142        wfns_fut: impl Future<Output = api::Result<NextSession>>,
143    ) -> impl actix_web::Responder {
144        match wfns_fut.await {
145            Ok(None) => actix_web::HttpResponse::NoContent().finish(),
146            Ok(Some(session_request)) => {
147                log::debug!("sent chained session to yivi server");
148                actix_web::HttpResponse::Ok().json(session_request)
149            }
150            Err(api::ErrorCode::InternalError) => {
151                actix_web::HttpResponse::InternalServerError().finish()
152            }
153            Err(api::ErrorCode::BadRequest) => actix_web::HttpResponse::BadRequest().finish(),
154            Err(api::ErrorCode::PleaseRetry) => panic!("not expecting 'please retry' here"),
155        }
156    }
157
158    fn dripping_wfns_responder(
159        wfns_fut: impl Future<Output = api::Result<NextSession>> + 'static,
160    ) -> impl actix_web::Responder {
161        let drips = tokio_stream::wrappers::IntervalStream::new(tokio::time::interval(
162            core::time::Duration::from_millis(100),
163        ))
164        .map(|_| Ok(bytes::Bytes::from_static(b" ")));
165
166        let result_bytes_fut = wfns_fut.map(|result| match result {
167            Ok(Some(session_request)) => {
168                log::debug!("sent chained session to yivi server: {session_request:?}");
169                Ok(serde_json::to_vec_pretty(&session_request)
170                    .map_err(|err| {
171                        log::error!("failed to serialize session_request to json: {err:#}");
172                        api::ErrorCode::InternalError
173                    })?
174                    .into())
175            }
176            Ok(None) => {
177                log::error!("bug: `None` session_request reached dripping chained session");
178                Err(api::ErrorCode::InternalError)
179            }
180            Err(err) => {
181                log::warn!("failed to release session_request to yivi server: {err:#}");
182                Err(err)
183            }
184        });
185
186        let stream = drips.until_overridden_by(result_bytes_fut.into_stream());
187
188        actix_web::HttpResponse::Ok()
189            // NB: Content-Type is not checked by irmago at the moment
190            .content_type(mime::APPLICATION_JSON)
191            .streaming(stream)
192    }
193
194    /// Implements the [`api::auths::YiviReleaseNextSessionEP`] endpoint.
195    pub async fn handle_yivi_release_next_session(
196        app: Rc<Self>,
197        req: web::Json<api::auths::YiviReleaseNextSessionReq>,
198    ) -> api::Result<api::auths::YiviReleaseNextSessionResp> {
199        let csc = app.chained_sessions_ctl_or_bad_request()?;
200        let yivi = app.get_yivi()?;
201
202        let api::auths::YiviReleaseNextSessionReq {
203            state,
204            next_session,
205            stale_after,
206        } = req.into_inner();
207
208        let Some(state) = AuthState::unseal(&state, &app.auth_state_secret) else {
209            return Ok(api::auths::YiviReleaseNextSessionResp::PleaseRestartAuth);
210        };
211
212        let Some(ChainedSessionSetup {
213            id: session_id,
214            drip,
215        }) = state.yivi_chained_session
216        else {
217            log::debug!(
218                "yivi-release-next-session endpoint called on a authentication session without a yivi chained session"
219            );
220            return Err(api::ErrorCode::BadRequest);
221        };
222
223        if next_session.is_none() && drip {
224            log::debug!(
225                "yivi-release-next-session endpoint on a dripping chained session, but with empty next session."
226            );
227            return Err(api::ErrorCode::BadRequest);
228        }
229
230        let esr = if let Some(jwt) = next_session {
231            Some(
232                yivi::ExtendedSessionRequest::open_signed(
233                    &jwt,
234                    &yivi.requestor_creds.to_verifying_credentials(),
235                )
236                .map_err(|err| {
237                    log::debug!("failed to open signed extended session request: {}", err);
238                    api::ErrorCode::BadRequest
239                })?,
240            )
241        } else {
242            None
243        };
244
245        csc.release_next_session(session_id, esr, stale_after).await
246    }
247}
248
249/// Keeps track of chained sessions
250///
251/// Create using [`Self::new`].  Cheaply cloneable.
252#[derive(Clone)]
253pub struct ChainedSessionsCtl {
254    sender: tokio::sync::mpsc::Sender<CscCommand>,
255}
256
257#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
258pub struct ChainedSessionsConfig {
259    #[serde(with = "crate::misc::time_ext::human_duration")]
260    #[serde(default = "default_chained_session_validity")]
261    pub session_validity: core::time::Duration,
262
263    /// Maximum number of concurrent chained sessions.
264    ///
265    /// This is a budget on long-lived connections, not on memory: each pending chained session can
266    /// tie up a `wait-for-result` connection (browser) and a `next-session` connection (Yivi
267    /// server, woken ~10×/second in drip mode), all drawn from the shared connection pool. A
268    /// too-high value would let anonymously-started chained sessions starve ordinary login/hub
269    /// traffic, so keep this well below the number of connections the server can sustain. Once the
270    /// maximum is reached new chained sessions are refused with
271    /// [`api::auths::AuthStartResp::ChainedSessionsTemporarilyUnavailable`] until the
272    /// `session_validity` expiry drains the table; in-flight sessions are never evicted.
273    #[serde(default = "default_max_chained_sessions")]
274    pub max_sessions: usize,
275}
276
277fn default_chained_session_validity() -> core::time::Duration {
278    core::time::Duration::from_secs(10 * 60) // 10 minutes
279}
280
281fn default_max_chained_sessions() -> usize {
282    1000
283}
284
285impl Default for ChainedSessionsConfig {
286    fn default() -> Self {
287        crate::misc::serde_ext::default_object()
288    }
289}
290
291type NextSession = Option<yivi::ExtendedSessionRequest>;
292
293type CreateSessionCrs = tokio::sync::oneshot::Sender<api::Result<Option<id::Id>>>;
294type WaitForResultCrs =
295    tokio::sync::oneshot::Sender<api::Result<api::auths::YiviWaitForResultResp>>;
296type WaitForNextSessionCrs = tokio::sync::oneshot::Sender<api::Result<NextSession>>;
297type ReleaseNextSessionCrs =
298    tokio::sync::oneshot::Sender<api::Result<api::auths::YiviReleaseNextSessionResp>>;
299
300enum CscCommand {
301    CreateSession {
302        resp_sender: CreateSessionCrs,
303    },
304    WaitForResult {
305        chained_session_id: id::Id,
306        resp_sender: WaitForResultCrs,
307    },
308    WaitForNextSession {
309        chained_session_id: id::Id,
310
311        /// `request_id` should be random and is passed in `Self::AbortWaitForNextSession` to abort
312        request_id: id::Id,
313        disclosure: jwt::JWT,
314        resp_sender: WaitForNextSessionCrs,
315    },
316    AbortWaitForNextSession {
317        chained_session_id: id::Id,
318        request_id: id::Id,
319    },
320    ReleaseNextSession {
321        chained_session_id: id::Id,
322        next_session_request: NextSession,
323        stale_after: Option<u16>,
324        resp_sender: ReleaseNextSessionCrs,
325    },
326}
327
328impl ChainedSessionsCtl {
329    /// Creates a new chained session, returning its [`id::Id`].
330    ///
331    /// Returns `Ok(None)` when the configured maximum number of concurrent chained sessions
332    /// ([`ChainedSessionsConfig::max_sessions`]) has been reached.
333    pub async fn create_session(&self) -> api::Result<Option<id::Id>> {
334        let (resp_sender, resp_receiver) = tokio::sync::oneshot::channel();
335
336        self.send_command(CscCommand::CreateSession { resp_sender })
337            .await?;
338
339        let Ok(resp) = resp_receiver.await else {
340            log::warn!("chained session control create-session response channel closed early");
341            return Err(api::ErrorCode::InternalError);
342        };
343
344        resp
345    }
346
347    /// Wait for the disclosure to arrive for the given chained session
348    pub async fn wait_for_result(
349        &self,
350        chained_session_id: id::Id,
351    ) -> api::Result<api::auths::YiviWaitForResultResp> {
352        let (resp_sender, resp_receiver) = tokio::sync::oneshot::channel();
353
354        self.send_command(CscCommand::WaitForResult {
355            chained_session_id,
356            resp_sender,
357        })
358        .await?;
359
360        let Ok(resp) = resp_receiver.await else {
361            log::warn!("chained session control wait-for-result response channel closed early");
362            return Err(api::ErrorCode::InternalError);
363        };
364
365        resp
366    }
367
368    /// Registers incoming disclosure and waits for the next session.
369    ///
370    /// Returns `None` if the yivi session is to be ended normally, without starting a next
371    /// session.
372    pub async fn wait_for_next_session(
373        self,
374        chained_session_id: id::Id,
375        request_id: id::Id,
376        disclosure: jwt::JWT,
377    ) -> api::Result<NextSession> {
378        let (resp_sender, resp_receiver) = tokio::sync::oneshot::channel();
379
380        self.send_command(CscCommand::WaitForNextSession {
381            chained_session_id,
382            request_id,
383            disclosure,
384            resp_sender,
385        })
386        .await?;
387
388        let Ok(resp) = resp_receiver.await else {
389            log::warn!(
390                "chained session control wait-for-next-session response channel closed early"
391            );
392            return Err(api::ErrorCode::InternalError);
393        };
394
395        resp
396    }
397
398    /// Hands the next session request (if any) to the waiting yivi server
399    pub async fn release_next_session(
400        &self,
401        chained_session_id: id::Id,
402        next_session_request: NextSession,
403        stale_after: Option<u16>,
404    ) -> api::Result<api::auths::YiviReleaseNextSessionResp> {
405        let (resp_sender, resp_receiver) = tokio::sync::oneshot::channel();
406
407        self.send_command(CscCommand::ReleaseNextSession {
408            chained_session_id,
409            next_session_request,
410            stale_after,
411            resp_sender,
412        })
413        .await?;
414
415        let Ok(resp) = resp_receiver.await else {
416            log::warn!(
417                "chained session control release-next-session response channel closed early"
418            );
419            return Err(api::ErrorCode::InternalError);
420        };
421
422        resp
423    }
424
425    async fn send_command(&self, cmd: CscCommand) -> api::Result<()> {
426        self.sender.send(cmd).await.map_err(|_| {
427            log::warn!("chained session control command channel closed early");
428            api::ErrorCode::InternalError
429        })
430    }
431
432    /// Creates a new [`ChainedSessionsCtl`] instance, and spawns a background task to drive it.
433    pub fn new(ctx: YiviCtx) -> Self {
434        let (sender, receiver) = tokio::sync::mpsc::channel(10);
435
436        tokio::spawn(async {
437            log::trace!("spawned chained sessions control task");
438
439            ChainedSessionsCtl::drive(ctx, receiver).await;
440
441            log::trace!("chained sessions control task is about to complete");
442        });
443
444        Self { sender }
445    }
446
447    async fn drive(ctx: YiviCtx, mut receiver: tokio::sync::mpsc::Receiver<CscCommand>) {
448        let mut backend = ChainedSessionsBackend::new(ctx);
449
450        loop {
451            tokio::select! {
452                cmd_maybe = receiver.recv() => {
453                    let Some(cmd) = cmd_maybe else {
454                        // channel is closed, no more commands are coming, so we can abort
455                        return
456                    };
457
458                    backend.handle_cmd(cmd).await;
459                }
460                _ = backend.sleep_until_next_expiry() => {
461                    backend.expire_next();
462                }
463            };
464        }
465    }
466}
467
468enum ChainedSessionState {
469    WaitingForYiviServer {
470        waiters: Vec<WaitForResultCrs>,
471    },
472    YiviServerWaiting {
473        disclosure: jwt::JWT,
474
475        /// Yivi servers waiting to be released.  The `Id` refers to the yivi server.
476        waiters: HashMap<id::Id, WaitForNextSessionCrs>,
477        first_arrived_at: std::time::Instant,
478    },
479}
480
481/// Backend to [`ChainedSessionsCtl`].
482struct ChainedSessionsBackend {
483    ctx: YiviCtx,
484    sessions: HashMap<id::Id, ChainedSessionState>,
485
486    /// Session ids ordered by expiry instant.  The session that will expire soonest
487    /// is in the front.  May contain ids already removed from [`Self::sessions`]
488    /// (namely, sessions that completed normally before expiry).
489    expiry_queue: VecDeque<(tokio::time::Instant, id::Id)>,
490}
491
492impl ChainedSessionsBackend {
493    fn new(ctx: YiviCtx) -> Self {
494        Self {
495            ctx,
496            sessions: Default::default(),
497            expiry_queue: Default::default(),
498        }
499    }
500
501    async fn handle_cmd(&mut self, cmd: CscCommand) {
502        match cmd {
503            CscCommand::WaitForResult {
504                chained_session_id,
505                resp_sender,
506            } => {
507                self.handle_wait_for_result(chained_session_id, resp_sender)
508                    .await
509            }
510            CscCommand::WaitForNextSession {
511                chained_session_id,
512                request_id,
513                disclosure,
514                resp_sender,
515            } => {
516                self.handle_wait_for_next_session(
517                    chained_session_id,
518                    request_id,
519                    disclosure,
520                    resp_sender,
521                )
522                .await
523            }
524            CscCommand::AbortWaitForNextSession {
525                chained_session_id,
526                request_id,
527            } => {
528                self.handle_abort_wait_for_next_session(chained_session_id, request_id)
529                    .await
530            }
531            CscCommand::CreateSession { resp_sender } => {
532                self.handle_create_session(resp_sender).await
533            }
534            CscCommand::ReleaseNextSession {
535                chained_session_id,
536                next_session_request,
537                stale_after,
538                resp_sender,
539            } => {
540                self.handle_release_next_session(
541                    chained_session_id,
542                    next_session_request,
543                    stale_after,
544                    resp_sender,
545                )
546                .await
547            }
548        }
549    }
550
551    fn respond_to<T>(
552        resp_sender: tokio::sync::oneshot::Sender<T>,
553        resp: T,
554        chained_session_id: id::Id,
555    ) {
556        if resp_sender.send(resp).is_err() {
557            log::warn!(
558                "response channel for chained session {chained_session_id} was closed before response could be sent"
559            );
560        }
561    }
562
563    async fn handle_create_session(&mut self, resp_sender: CreateSessionCrs) {
564        // `sessions.len()` is the live count; `expiry_queue` may still hold ids of sessions that
565        // already completed (see its docstring), so it must not be used here.
566        let max_sessions = self.ctx.chained_sessions_config.max_sessions;
567        if self.sessions.len() >= max_sessions {
568            log::warn!(
569                "refusing new chained session: at the configured maximum of {max_sessions} concurrent chained sessions"
570            );
571            if resp_sender.send(Ok(None)).is_err() {
572                log::warn!(
573                    "create-session response channel closed before capacity refusal could be sent"
574                );
575            }
576            return;
577        }
578
579        let chained_session_id = id::Id::random();
580
581        assert!(
582            self.sessions
583                .insert(
584                    chained_session_id,
585                    ChainedSessionState::WaitingForYiviServer { waiters: vec![] }
586                )
587                .is_none(),
588            "against all odds, 256-bit random ids collided!"
589        );
590
591        let expiry =
592            tokio::time::Instant::now() + self.ctx.chained_sessions_config.session_validity;
593        self.expiry_queue.push_back((expiry, chained_session_id));
594
595        log::trace!("chained session {chained_session_id} created");
596
597        Self::respond_to(
598            resp_sender,
599            Ok(Some(chained_session_id)),
600            chained_session_id,
601        );
602    }
603
604    async fn handle_wait_for_result(
605        &mut self,
606        chained_session_id: id::Id,
607        resp_sender: WaitForResultCrs,
608    ) {
609        let Some(session) = self.sessions.get_mut(&chained_session_id) else {
610            Self::respond_to(
611                resp_sender,
612                Ok(api::auths::YiviWaitForResultResp::SessionGone),
613                chained_session_id,
614            );
615            return;
616        };
617
618        match session {
619            ChainedSessionState::WaitingForYiviServer { waiters } => {
620                log::trace!(
621                    "registered waiter for the result of chained session {chained_session_id}",
622                );
623                waiters.push(resp_sender)
624            }
625            ChainedSessionState::YiviServerWaiting { disclosure, .. } => {
626                log::trace!(
627                    "result for chained session {chained_session_id} requested and immediately available"
628                );
629                Self::respond_to(
630                    resp_sender,
631                    Ok(api::auths::YiviWaitForResultResp::Success {
632                        disclosure: disclosure.clone(),
633                    }),
634                    chained_session_id,
635                )
636            }
637        }
638    }
639
640    async fn handle_wait_for_next_session(
641        &mut self,
642        chained_session_id: id::Id,
643        request_id: id::Id,
644        disclosure: jwt::JWT,
645        resp_sender: WaitForNextSessionCrs,
646    ) {
647        let Some(session) = self.sessions.get_mut(&chained_session_id) else {
648            log::warn!(
649                "yivi server submitted disclosure for a chained session {chained_session_id} that cannot be found - was the yivi server too slow?"
650            );
651            Self::respond_to(resp_sender, Ok(None), chained_session_id);
652            return;
653        };
654
655        // check session is ready for the yivi server
656        match session {
657            ChainedSessionState::WaitingForYiviServer { .. } => {
658                // this is what we want; handled below
659            }
660            ChainedSessionState::YiviServerWaiting {
661                disclosure: stored_disclosure,
662                waiters,
663                ..
664            } => {
665                if &disclosure != stored_disclosure {
666                    log::warn!(
667                        "second yivi server submitted a different disclosure for chained session {chained_session_id}"
668                    );
669                    Self::respond_to(
670                        resp_sender,
671                        Err(api::ErrorCode::BadRequest),
672                        chained_session_id,
673                    );
674                    return;
675                }
676                log::trace!(
677                    "an additional yivi server ({request_id}) is waiting for chained session {chained_session_id}"
678                );
679                let should_be_none = waiters.insert(request_id, resp_sender);
680                if should_be_none.is_some() {
681                    log::error!("bug: 256-bit random `request_id` collided - was it random?");
682                    panic!("bug: random `request_id`s collided");
683                }
684                return;
685            }
686        }
687        let old_session = std::mem::replace(
688            session,
689            ChainedSessionState::YiviServerWaiting {
690                disclosure: disclosure.clone(),
691                waiters: [(request_id, resp_sender)].into(),
692                first_arrived_at: std::time::Instant::now(),
693            },
694        );
695
696        match old_session {
697            ChainedSessionState::WaitingForYiviServer { waiters } => {
698                // release waiters
699                log::trace!(
700                    "releasing {} waiter(s) on the result of chained session {chained_session_id}",
701                    waiters.len()
702                );
703                for waiter in waiters.into_iter() {
704                    Self::respond_to(
705                        waiter,
706                        Ok(api::auths::YiviWaitForResultResp::Success {
707                            disclosure: disclosure.clone(),
708                        }),
709                        chained_session_id,
710                    )
711                }
712            }
713            ChainedSessionState::YiviServerWaiting { .. } => {
714                panic!("session changed unexpectedly");
715            }
716        }
717    }
718
719    async fn handle_abort_wait_for_next_session(
720        &mut self,
721        chained_session_id: id::Id,
722        request_id: id::Id,
723    ) {
724        let Some(session) = self.sessions.get_mut(&chained_session_id) else {
725            log::trace!(
726                "wanting to abort yivi server request {request_id} of chained session \
727                {chained_session_id}, but this session is not there (anymore)"
728            );
729            return;
730        };
731
732        match session {
733            ChainedSessionState::WaitingForYiviServer { .. } => {
734                log::warn!(
735                    "to be aborted yivi server request {request_id} of chained session \
736                    {chained_session_id} is not (yet) recorded in chained session state \
737                    (nor any other yivi server request)"
738                );
739            }
740            ChainedSessionState::YiviServerWaiting { waiters, .. } => {
741                let removed = waiters.remove(&request_id);
742
743                if removed.is_none() {
744                    log::warn!(
745                        "to be aborted yivi server request {request_id} of chained session \
746                        {chained_session_id} is not (yet) recorded in chained session state"
747                    );
748                }
749
750                // NOTE: there is no need to actually send anything over the response sender in
751                // `removed` back to the waiting yivi server, because it has aborted.
752            }
753        }
754    }
755
756    async fn handle_release_next_session(
757        &mut self,
758        chained_session_id: id::Id,
759        next_session_request: NextSession,
760        stale_after: Option<u16>,
761        resp_sender: ReleaseNextSessionCrs,
762    ) {
763        let Some(session) = self.sessions.get_mut(&chained_session_id) else {
764            log::debug!(
765                "request to release chained session {chained_session_id} that cannot be found "
766            );
767            Self::respond_to(
768                resp_sender,
769                Ok(api::auths::YiviReleaseNextSessionResp::SessionGone),
770                chained_session_id,
771            );
772            return;
773        };
774
775        // check session is ready for the yivi server
776        match session {
777            ChainedSessionState::WaitingForYiviServer { .. } => {
778                log::debug!(
779                    "request to release a yivi server that's not there yet, \
780                    in chained session {chained_session_id}"
781                );
782                Self::respond_to(
783                    resp_sender,
784                    Ok(api::auths::YiviReleaseNextSessionResp::TooEarly),
785                    chained_session_id,
786                );
787                return;
788            }
789            ChainedSessionState::YiviServerWaiting { .. } => {
790                // this is what we want
791            }
792        }
793
794        log::trace!(
795            "yivi server is about to be released from chained session {chained_session_id}"
796        );
797        let Some(ChainedSessionState::YiviServerWaiting {
798            waiters,
799            first_arrived_at,
800            ..
801        }) = self.sessions.remove(&chained_session_id)
802        else {
803            panic!("chained session state changed unexpectedly");
804        };
805
806        if waiters.is_empty() {
807            Self::respond_to(
808                resp_sender,
809                Ok(api::auths::YiviReleaseNextSessionResp::YiviServerGone),
810                chained_session_id,
811            );
812            return;
813        }
814
815        if let Some(stale_after) = stale_after
816            && std::time::Instant::now()
817                .duration_since(first_arrived_at)
818                .as_millis()
819                > stale_after.into()
820        {
821            Self::respond_to(
822                resp_sender,
823                Ok(api::auths::YiviReleaseNextSessionResp::YiviServerGone),
824                chained_session_id,
825            );
826            return;
827        }
828
829        for waiter in waiters.into_values() {
830            Self::respond_to(waiter, Ok(next_session_request.clone()), chained_session_id);
831        }
832
833        Self::respond_to(
834            resp_sender,
835            Ok(api::auths::YiviReleaseNextSessionResp::Success {}),
836            chained_session_id,
837        );
838    }
839
840    async fn sleep_until_next_expiry(&self) {
841        match self.expiry_queue.front() {
842            Some((expiry, _)) => tokio::time::sleep_until(*expiry).await,
843            None => std::future::pending().await,
844        }
845    }
846
847    fn expire_next(&mut self) {
848        let now = tokio::time::Instant::now();
849
850        while let Some((_, id)) = self.expiry_queue.pop_front_if(|(expiry, _)| *expiry <= now) {
851            let Some(session) = self.sessions.remove(&id) else {
852                continue; // already completed normally
853            };
854
855            log::debug!("chained session {id} expired");
856            match session {
857                ChainedSessionState::WaitingForYiviServer { waiters } => {
858                    for waiter in waiters {
859                        Self::respond_to(
860                            waiter,
861                            Ok(api::auths::YiviWaitForResultResp::SessionGone),
862                            id,
863                        );
864                    }
865                }
866                ChainedSessionState::YiviServerWaiting { waiters, .. } => {
867                    for waiter in waiters.into_values() {
868                        Self::respond_to(waiter, Ok(None), id);
869                    }
870                }
871            }
872        }
873    }
874}