Skip to main content

alibi_plugins/one_time_token/
mod.rs

1//! Short-lived, single-use credentials that hand an existing session to a client.
2
3mod endpoint;
4
5use super::authentication_helpers::{
6    JsonField, RequestBody, dont_remember_preference, parse_body, with_session_cookies,
7};
8use super::helpers::{response_session, unauthorized_if_unauthenticated};
9use alibi_core::wire::{SessionView, UserView};
10use alibi_core::{
11    AuthContext, AuthError, AuthPlugin, AuthRequest, AuthResponse, AuthResult, AuthRoute,
12    AuthSchema, CreateVerification, HttpMethod,
13};
14use async_trait::async_trait;
15use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
16use chrono::{Duration, Utc};
17pub use endpoint::OneTimeTokenOutput;
18use rand::seq::IndexedRandom as _;
19use serde::{Deserialize, Serialize};
20use serde_json::json;
21use sha2::{Digest, Sha256};
22use std::sync::Arc;
23
24/// The authenticated account and session represented by a one-time token.
25#[derive(Clone, Debug, Serialize, Deserialize)]
26pub struct OneTimeTokenSession {
27    pub session: SessionView,
28    pub user: UserView,
29}
30
31/// A consumed token's user and session, or the public reason it resolves to none.
32type ConsumedSession<S> = Result<
33    (
34        <S as AuthSchema>::User,
35        alibi_core::AdapterRecord<<S as AuthSchema>::Session>,
36    ),
37    &'static str,
38>;
39
40/// Application-owned token generation, including asynchronous generators.
41///
42/// Return [`AuthError::Api`] or [`AuthError::Upstream`] for an explicit public
43/// veto. Other callback errors produce an empty HTTP 500 without exposing the cause.
44#[async_trait]
45pub trait GenerateOneTimeToken: Send + Sync {
46    async fn generate(
47        &self,
48        session: &OneTimeTokenSession,
49        request: Option<&AuthRequest>,
50    ) -> AuthResult<String>;
51}
52
53/// Application-owned hashing applied consistently at issuance and consumption.
54///
55/// Errors follow the same public-veto/ordinary-failure contract as
56/// [`GenerateOneTimeToken`], before any verification is created or consumed.
57#[async_trait]
58pub trait HashOneTimeToken: Send + Sync {
59    async fn hash(&self, token: &str) -> AuthResult<String>;
60}
61
62#[derive(Clone, Default)]
63pub enum OneTimeTokenStorage {
64    #[default]
65    Plain,
66    Hashed,
67    Custom(Arc<dyn HashOneTimeToken>),
68}
69
70impl std::fmt::Debug for OneTimeTokenStorage {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        match self {
73            Self::Plain => f.write_str("OneTimeTokenStorage::Plain"),
74            Self::Hashed => f.write_str("OneTimeTokenStorage::Hashed"),
75            Self::Custom(..) => f.write_str("OneTimeTokenStorage::Custom"),
76        }
77    }
78}
79
80#[derive(Clone)]
81pub struct OneTimeTokenConfig {
82    pub expires_in: Duration,
83    pub storage: OneTimeTokenStorage,
84    pub generator: Option<Arc<dyn GenerateOneTimeToken>>,
85    pub disable_client_request: bool,
86    pub disable_set_session_cookie: bool,
87    pub set_ott_header_on_new_session: bool,
88}
89
90impl std::fmt::Debug for OneTimeTokenConfig {
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        f.debug_struct("OneTimeTokenConfig").finish_non_exhaustive()
93    }
94}
95
96impl Default for OneTimeTokenConfig {
97    fn default() -> Self {
98        Self {
99            expires_in: Duration::minutes(3),
100            storage: OneTimeTokenStorage::Plain,
101            generator: None,
102            disable_client_request: false,
103            disable_set_session_cookie: false,
104            set_ott_header_on_new_session: false,
105        }
106    }
107}
108
109#[derive(Clone, Default)]
110pub struct OneTimeTokenPlugin {
111    config: OneTimeTokenConfig,
112}
113
114impl std::fmt::Debug for OneTimeTokenPlugin {
115    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116        f.debug_struct("OneTimeTokenPlugin").finish_non_exhaustive()
117    }
118}
119
120impl OneTimeTokenPlugin {
121    #[must_use]
122    pub fn new() -> Self {
123        Self::default()
124    }
125    #[must_use]
126    pub const fn with_config(config: OneTimeTokenConfig) -> Self {
127        Self { config }
128    }
129
130    /// Issue a token for a trusted, authenticated server-side session.
131    ///
132    /// The caller supplies the session it has already authenticated. The token
133    /// preserves that session's identity and expiry; it does not create one.
134    ///
135    /// # Errors
136    ///
137    /// Returns an error if token generation, hashing, or persistence fails.
138    pub async fn generate_for_session(
139        &self,
140        session: &OneTimeTokenSession,
141        request: Option<&AuthRequest>,
142        ctx: &AuthContext<impl AuthSchema>,
143    ) -> AuthResult<String> {
144        let token = match &self.config.generator {
145            Some(generator) => generator
146                .generate(session, request)
147                .await
148                .map_err(crate::helpers::callback_failure)?,
149            None => random_token(),
150        };
151        let stored = self.stored_token(&token).await?;
152        _ = ctx
153            .verifications()
154            .create(CreateVerification {
155                identifier: format!("one-time-token:{stored}"),
156                value: session.session.token.clone(),
157                expires_at: Utc::now() + self.config.expires_in,
158            })
159            .await?;
160        Ok(token)
161    }
162
163    /// Consume a token and resolve its existing session for a server caller.
164    ///
165    /// # Errors
166    ///
167    /// Returns an error for a missing, expired, or invalid token/session, or if storage fails.
168    pub async fn verify_token(
169        &self,
170        token: &str,
171        ctx: &AuthContext<impl AuthSchema>,
172    ) -> AuthResult<OneTimeTokenSession> {
173        let session = self.consume_session(token, ctx).await?;
174        if session.session.expires_at < Utc::now() {
175            return Err(AuthError::bad_request("Session expired"));
176        }
177        Ok(session)
178    }
179
180    async fn stored_token(&self, token: &str) -> AuthResult<String> {
181        match &self.config.storage {
182            OneTimeTokenStorage::Plain => Ok(token.to_owned()),
183            OneTimeTokenStorage::Hashed => {
184                Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(token.as_bytes())))
185            }
186            OneTimeTokenStorage::Custom(hasher) => hasher
187                .hash(token)
188                .await
189                .map_err(crate::helpers::callback_failure),
190        }
191    }
192
193    async fn consume_session(
194        &self,
195        token: &str,
196        ctx: &AuthContext<impl AuthSchema>,
197    ) -> AuthResult<OneTimeTokenSession> {
198        let (user, session) = self
199            .consume_stored_session(token, ctx)
200            .await?
201            .map_err(AuthError::bad_request)?;
202        Ok(OneTimeTokenSession {
203            session: ctx.session_view(&session),
204            user: ctx.user_view(&user),
205        })
206    }
207
208    async fn consume_stored_session<S: AuthSchema>(
209        &self,
210        token: &str,
211        ctx: &AuthContext<S>,
212    ) -> AuthResult<ConsumedSession<S>> {
213        let stored = self.stored_token(token).await?;
214        let Some(verification) = ctx
215            .verifications()
216            .consume(&format!("one-time-token:{stored}"))
217            .await?
218        else {
219            return Ok(Err("Invalid token"));
220        };
221        let Some(session) = ctx
222            .database
223            .get_session_record(verification.value()?)
224            .await?
225        else {
226            return Ok(Err("Session not found"));
227        };
228        let Some(user) = ctx.session_user(&session).await? else {
229            return Ok(Err("Session not found"));
230        };
231        Ok(Ok((user, session)))
232    }
233
234    async fn generate(
235        &self,
236        req: &AuthRequest,
237        ctx: &AuthContext<impl AuthSchema>,
238    ) -> AuthResult<AuthResponse> {
239        let (user, session) = ctx
240            .require_cached_session(req)
241            .await
242            .map_err(unauthorized_if_unauthenticated)?;
243        if self.config.disable_client_request {
244            return message_response(400, "Client requests are disabled");
245        }
246        let token = self
247            .generate_for_session(
248                &OneTimeTokenSession {
249                    session: ctx.session_view(&session),
250                    user: ctx.user_view(&user),
251                },
252                Some(req),
253                ctx,
254            )
255            .await?;
256        Ok(AuthResponse::json(200, &json!({ "token": token }))?)
257    }
258
259    async fn verify(
260        &self,
261        req: &AuthRequest,
262        ctx: &AuthContext<impl AuthSchema>,
263    ) -> AuthResult<AuthResponse> {
264        let body: VerifyRequest = match parse_body(req) {
265            Ok(body) => body,
266            Err(response) => return Ok(response),
267        };
268        let session = match self.consume_session(&body.token, ctx).await {
269            Ok(session) => session,
270            Err(AuthError::BadRequest(message)) => return message_response(400, &message),
271            Err(error) => return Err(error),
272        };
273        if !self.config.disable_set_session_cookie {
274            // Source republishes the existing adapter session's parsed views;
275            // verification does not create a replacement session.
276            alibi_core::session::cookie_cache::runtime::emit_issuance_snapshot(
277                ctx,
278                alibi_core::CacheVersionContext::created(
279                    session.user.clone(),
280                    session.session.clone(),
281                    session.user.clone(),
282                    session.session.clone(),
283                ),
284            )
285            .await?;
286        }
287        let mut response = if session.session.expires_at < Utc::now() {
288            message_response(400, "Session expired")?
289        } else {
290            AuthResponse::json(200, &session)?
291        };
292        // The reference sets the existing session cookie before checking its
293        // expiry, including on the expired-session rejection response.
294        if !self.config.disable_set_session_cookie {
295            let dont_remember = dont_remember_preference(req, &ctx.config);
296            response =
297                with_session_cookies(response, &ctx.config, &session.session.token, dont_remember)?;
298        }
299        Ok(response)
300    }
301}
302
303#[derive(Deserialize)]
304struct VerifyRequest {
305    token: String,
306}
307
308impl RequestBody for VerifyRequest {
309    const FIELDS: &'static [JsonField] = &[JsonField::string("token", true)];
310}
311
312#[async_trait]
313impl<S: AuthSchema> AuthPlugin<S> for OneTimeTokenPlugin {
314    route_openapi_metadata!(S);
315
316    fn name(&self) -> &'static str {
317        "one-time-token"
318    }
319    fn routes(&self) -> Vec<AuthRoute> {
320        vec![
321            AuthRoute::get("/one-time-token/generate", "generate_one_time_token"),
322            AuthRoute::post("/one-time-token/verify", "verify_one_time_token"),
323        ]
324    }
325    fn endpoint_hooks(&self) -> Vec<&dyn alibi_core::endpoint::EndpointHook<S>> {
326        vec![self]
327    }
328    fn server_endpoints(&self) -> Vec<alibi_core::endpoint::EndpointDefinition> {
329        endpoint::definitions()
330    }
331
332    fn validate_endpoint(
333        &self,
334        call: &alibi_core::endpoint::EndpointCall,
335        _ctx: &AuthContext<S>,
336    ) -> AuthResult<alibi_core::endpoint::EndpointInput> {
337        endpoint::validate(call)
338    }
339
340    async fn on_endpoint(
341        &self,
342        call: &alibi_core::endpoint::EndpointCall,
343        ctx: &AuthContext<S>,
344    ) -> AuthResult<alibi_core::endpoint::EndpointResponse> {
345        self.call_endpoint(call, ctx).await
346    }
347
348    async fn on_request(
349        &self,
350        req: &AuthRequest,
351        ctx: &AuthContext<S>,
352    ) -> AuthResult<Option<AuthResponse>> {
353        let response = match (req.method(), req.path()) {
354            (HttpMethod::Get, "/one-time-token/generate") => self.generate(req, ctx).await?,
355            (HttpMethod::Post, "/one-time-token/verify") => self.verify(req, ctx).await?,
356            _ => return Ok(None),
357        };
358        Ok(Some(response))
359    }
360    async fn after_request(
361        &self,
362        req: &AuthRequest,
363        ctx: &AuthContext<S>,
364        mut response: AuthResponse,
365    ) -> AuthResult<AuthResponse> {
366        if !self.config.set_ott_header_on_new_session {
367            return Ok(response);
368        }
369        if let Some(session) = response_session(ctx, &response).await? {
370            let token = self
371                .generate_for_session(
372                    &OneTimeTokenSession {
373                        session: ctx.session_view(&session.session),
374                        user: ctx.user_view(&session.user),
375                    },
376                    Some(req),
377                    ctx,
378                )
379                .await?;
380            let expose = exposed_headers(&response.headers);
381            _ = response.headers.insert("set-ott", token);
382            _ = response
383                .headers
384                .insert("access-control-expose-headers", expose);
385        }
386        Ok(response)
387    }
388}
389
390#[async_trait]
391impl<S: AuthSchema> alibi_core::endpoint::EndpointHook<S> for OneTimeTokenPlugin {
392    fn matches_after(
393        &self,
394        _call: &alibi_core::endpoint::EndpointCall,
395        _ctx: &AuthContext<S>,
396        _response: &alibi_core::endpoint::EndpointResponse,
397    ) -> AuthResult<bool> {
398        Ok(self.config.set_ott_header_on_new_session)
399    }
400    async fn after(
401        &self,
402        call: &alibi_core::endpoint::EndpointCall,
403        ctx: &AuthContext<S>,
404        mut response: alibi_core::endpoint::EndpointResponse,
405    ) -> AuthResult<alibi_core::endpoint::EndpointResponse> {
406        if let Some((user, session)) =
407            alibi_core::session::cookie_cache::runtime::published_session(call)
408        {
409            let token = self
410                .generate_for_session(&OneTimeTokenSession { session, user }, call.request(), ctx)
411                .await?;
412            let expose = exposed_headers(response.headers());
413            response = response
414                .with_header("set-ott", token)
415                .with_header("access-control-expose-headers", expose);
416        }
417        Ok(response)
418    }
419}
420fn exposed_headers(headers: &alibi_core::Headers) -> String {
421    let mut exposed = headers
422        .get("access-control-expose-headers")
423        .into_iter()
424        .flat_map(|value| value.split(','))
425        .map(str::trim)
426        .filter(|value| !value.is_empty())
427        .fold(Vec::<String>::new(), |mut headers, value| {
428            if !headers.iter().any(|header| header == value) {
429                headers.push(value.to_owned());
430            }
431            headers
432        });
433    if !exposed.iter().any(|header| header == "set-ott") {
434        exposed.push("set-ott".into());
435    }
436    exposed.join(", ")
437}
438
439fn message_response(status: u16, message: &str) -> AuthResult<AuthResponse> {
440    Ok(AuthResponse::json(status, &json!({ "message": message }))?)
441}
442
443fn random_token() -> String {
444    const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ-_";
445    (0..32)
446        .map(|_| char::from(ALPHABET.choose(&mut rand::rng()).copied().unwrap_or(b'a')))
447        .collect()
448}
449
450// LCOV_EXCL_START
451#[cfg(test)]
452mod tests {
453    #![allow(
454        clippy::unwrap_used,
455        reason = "asserted one-time-token contracts against real SQLite"
456    )]
457
458    use super::*;
459    use crate::test_helpers;
460    use alibi_core::CreateUser;
461    use alibi_core::utils::cookie_utils::{
462        related_cookie_name, sign_cookie_value, verify_cookie_value,
463    };
464
465    type TestSchema = alibi_seaorm::store::__private_test_support::bundled_schema::BundledSchema;
466
467    struct CustomHasher;
468
469    #[async_trait]
470    impl HashOneTimeToken for CustomHasher {
471        async fn hash(&self, token: &str) -> AuthResult<String> {
472            Ok(format!("custom:{token}"))
473        }
474    }
475
476    struct CustomGenerator;
477
478    #[async_trait]
479    impl GenerateOneTimeToken for CustomGenerator {
480        async fn generate(
481            &self,
482            session: &OneTimeTokenSession,
483            request: Option<&AuthRequest>,
484        ) -> AuthResult<String> {
485            assert_eq!(request.map(AuthRequest::path), None);
486            assert_ne!(session.user.id.len(), 0);
487            Ok("custom-generated-token".to_owned())
488        }
489    }
490
491    async fn setup() -> (AuthContext<TestSchema>, OneTimeTokenSession) {
492        let ctx = test_helpers::create_test_context().await;
493        let (user, session) = test_helpers::create_user_and_session(
494            &ctx,
495            CreateUser::new().with_email("one-time-token@fixture.test"),
496            Duration::days(1),
497        )
498        .await;
499        (ctx, OneTimeTokenSession { session, user })
500    }
501
502    fn signed_request(
503        path: &str,
504        session: &OneTimeTokenSession,
505        ctx: &AuthContext<impl AuthSchema>,
506    ) -> AuthRequest {
507        let mut req = test_helpers::create_auth_request_no_query(HttpMethod::Get, path, None, None);
508        req.headers.insert(
509            "cookie".to_owned(),
510            alibi_core::utils::cookie_utils::create_session_cookie(
511                &session.session.token,
512                &ctx.config,
513            )
514            .unwrap()
515            .split(';')
516            .next()
517            .unwrap()
518            .to_owned(),
519        );
520        req
521    }
522
523    #[tokio::test]
524    async fn default_tokens_are_persisted_consumed_once_and_reuse_the_original_session() {
525        let (ctx, session) = setup().await;
526        let plugin = OneTimeTokenPlugin::new();
527        let token = plugin
528            .generate_for_session(&session, None, &ctx)
529            .await
530            .unwrap();
531        assert_eq!(token.chars().count(), 32);
532        let identifier = format!("one-time-token:{token}");
533        let stored = ctx
534            .database
535            .get_latest_verification_by_identifier(&identifier)
536            .await
537            .unwrap()
538            .unwrap();
539        assert_eq!(stored.value, session.session.token);
540        assert!((stored.expires_at - Utc::now()).num_seconds() >= 179);
541        let verified = plugin.verify_token(&token, &ctx).await.unwrap();
542        assert_eq!(verified.session.token, session.session.token);
543        assert_eq!(verified.user.id, session.user.id);
544        assert!(
545            ctx.database
546                .get_latest_verification_by_identifier(&identifier)
547                .await
548                .unwrap()
549                .is_none()
550        );
551        assert!(
552            matches!(plugin.verify_token(&token, &ctx).await, Err(AuthError::BadRequest(message)) if message == "Invalid token")
553        );
554        assert_eq!(
555            ctx.database
556                .get_user_sessions(&session.user.id)
557                .await
558                .unwrap()
559                .len(),
560            1
561        );
562    }
563
564    #[tokio::test]
565    async fn expired_latest_generation_invalidates_older_live_token_and_revoked_session_consumes_the_token()
566     {
567        let (ctx, session) = setup().await;
568        let plugin = OneTimeTokenPlugin::new();
569        let identifier = "one-time-token:duplicate";
570        ctx.database
571            .create_verification(CreateVerification {
572                identifier: identifier.to_owned(),
573                value: session.session.token.clone(),
574                expires_at: Utc::now() + Duration::minutes(3),
575            })
576            .await
577            .unwrap();
578        ctx.database
579            .create_verification(CreateVerification {
580                identifier: identifier.to_owned(),
581                value: session.session.token.clone(),
582                expires_at: Utc::now() - Duration::seconds(1),
583            })
584            .await
585            .unwrap();
586        assert!(
587            matches!(plugin.verify_token("duplicate", &ctx).await, Err(AuthError::BadRequest(message)) if message == "Invalid token")
588        );
589        assert!(
590            ctx.database
591                .get_latest_verification_by_identifier(identifier)
592                .await
593                .unwrap()
594                .is_none()
595        );
596        let token = plugin
597            .generate_for_session(&session, None, &ctx)
598            .await
599            .unwrap();
600        ctx.database
601            .delete_session(&session.session.token)
602            .await
603            .unwrap();
604        assert!(
605            matches!(plugin.verify_token(&token, &ctx).await, Err(AuthError::BadRequest(message)) if message == "Session not found")
606        );
607        assert!(
608            matches!(plugin.verify_token(&token, &ctx).await, Err(AuthError::BadRequest(message)) if message == "Invalid token")
609        );
610    }
611
612    #[tokio::test]
613    async fn concurrent_token_redemption_has_exactly_one_winner() {
614        let (ctx, session) = setup().await;
615        let plugin = OneTimeTokenPlugin::new();
616        let token = plugin
617            .generate_for_session(&session, None, &ctx)
618            .await
619            .unwrap();
620        let (first, second) = tokio::join!(
621            plugin.verify_token(&token, &ctx),
622            plugin.verify_token(&token, &ctx)
623        );
624        assert_ne!(first.is_ok(), second.is_ok());
625        let verified = first.or(second).unwrap();
626        assert_eq!(verified.session.token, session.session.token);
627        assert_eq!(
628            ctx.database
629                .get_user_sessions(&session.user.id)
630                .await
631                .unwrap()
632                .len(),
633            1
634        );
635    }
636
637    #[tokio::test]
638    async fn hashed_and_custom_storage_share_consistent_issue_and_consume_paths() {
639        let (ctx, session) = setup().await;
640        for storage in [
641            OneTimeTokenStorage::Hashed,
642            OneTimeTokenStorage::Custom(Arc::new(CustomHasher)),
643        ] {
644            let plugin = OneTimeTokenPlugin::with_config(OneTimeTokenConfig {
645                storage,
646                generator: Some(Arc::new(CustomGenerator)),
647                ..Default::default()
648            });
649            let token = plugin
650                .generate_for_session(&session, None, &ctx)
651                .await
652                .unwrap();
653            let stored = plugin.stored_token(&token).await.unwrap();
654            assert_ne!(token, stored);
655            assert!(
656                ctx.database
657                    .get_latest_verification_by_identifier(&format!("one-time-token:{token}"))
658                    .await
659                    .unwrap()
660                    .is_none()
661            );
662            let row = ctx
663                .database
664                .get_latest_verification_by_identifier(&format!("one-time-token:{stored}"))
665                .await
666                .unwrap()
667                .unwrap();
668            assert_eq!(row.value, session.session.token);
669            assert_eq!(
670                plugin
671                    .verify_token(&token, &ctx)
672                    .await
673                    .unwrap()
674                    .session
675                    .token,
676                session.session.token
677            );
678        }
679    }
680
681    #[tokio::test]
682    #[expect(
683        clippy::too_many_lines,
684        reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
685    )]
686    async fn http_transfer_cookie_headers_and_server_only_configuration_are_observable() {
687        let (ctx, session) = setup().await;
688        let plugin = OneTimeTokenPlugin::with_config(OneTimeTokenConfig {
689            disable_client_request: true,
690            set_ott_header_on_new_session: true,
691            ..Default::default()
692        });
693        let req = signed_request("/one-time-token/generate", &session, &ctx);
694        assert_eq!(plugin.generate(&req, &ctx).await.unwrap().status, 400);
695        let token = plugin
696            .generate_for_session(&session, None, &ctx)
697            .await
698            .unwrap();
699        let verify = test_helpers::create_auth_json_request_no_query(
700            HttpMethod::Post,
701            "/one-time-token/verify",
702            None,
703            Some(json!({ "token": token })),
704        );
705        let response = plugin.verify(&verify, &ctx).await.unwrap();
706        assert_eq!(response.status, 200);
707        let cookie = response.headers.get("set-cookie").unwrap();
708        let cookie = cookie::Cookie::parse(cookie.clone()).unwrap();
709        assert_eq!(
710            verify_cookie_value(cookie.value(), &ctx.config.secret).as_deref(),
711            Some(session.session.token.as_str())
712        );
713        for (preference, persistent) in [
714            (None, true),
715            (Some(""), true),
716            (Some("false"), false),
717            (Some("true"), false),
718        ] {
719            let token_2 = plugin
720                .generate_for_session(&session, None, &ctx)
721                .await
722                .unwrap();
723            let mut verify_2 = test_helpers::create_auth_json_request_no_query(
724                HttpMethod::Post,
725                "/one-time-token/verify",
726                None,
727                Some(json!({ "token": token_2 })),
728            );
729            if let Some(value) = preference {
730                verify_2.headers.insert(
731                    "cookie".to_owned(),
732                    format!(
733                        "{}={}",
734                        related_cookie_name(&ctx.config, "dont_remember"),
735                        sign_cookie_value(value, &ctx.config.secret)
736                    ),
737                );
738            }
739            let verified = plugin.verify(&verify_2, &ctx).await.unwrap();
740            assert_eq!(verified.status, 200);
741            let cookies = verified
742                .headers
743                .get_all("set-cookie")
744                .map(|header| cookie::Cookie::parse(header.clone()).unwrap())
745                .collect::<Vec<_>>();
746            let session_cookie = cookies
747                .iter()
748                .find(|cookie_2| cookie_2.name() == ctx.config.session.cookie_name)
749                .unwrap();
750            assert_eq!(
751                cookies.iter().any(|candidate| candidate.name()
752                    == related_cookie_name(&ctx.config, "dont_remember")),
753                !persistent
754            );
755            assert_eq!(
756                session_cookie
757                    .max_age()
758                    .map(cookie::time::Duration::whole_seconds),
759                persistent.then_some(ctx.config.session.expires_in.num_seconds()),
760                "receiving preference {preference:?}"
761            );
762            assert_eq!(
763                verify_cookie_value(session_cookie.value(), &ctx.config.secret).as_deref(),
764                Some(session.session.token.as_str())
765            );
766        }
767        let mut response = response;
768        response.headers.insert(
769            "access-control-expose-headers",
770            " existing, ,existing, set-ott, set-ott, Existing ",
771        );
772        let hooked = plugin.after_request(&verify, &ctx, response).await.unwrap();
773        assert_eq!(
774            hooked.headers.get("access-control-expose-headers").unwrap(),
775            "existing, set-ott, Existing"
776        );
777        let delivered = plugin
778            .verify_token(hooked.headers.get("set-ott").unwrap(), &ctx)
779            .await
780            .unwrap();
781        assert_eq!(delivered.session.token, session.session.token);
782        assert_eq!(delivered.user.id, session.user.id);
783        let no_cookie = OneTimeTokenPlugin::with_config(OneTimeTokenConfig {
784            disable_set_session_cookie: true,
785            ..Default::default()
786        });
787        let token_3 = no_cookie
788            .generate_for_session(&session, None, &ctx)
789            .await
790            .unwrap();
791        let verify_3 = test_helpers::create_auth_json_request_no_query(
792            HttpMethod::Post,
793            "/one-time-token/verify",
794            None,
795            Some(json!({ "token": token_3 })),
796        );
797        assert!(
798            no_cookie
799                .verify(&verify_3, &ctx)
800                .await
801                .unwrap()
802                .headers
803                .get("set-cookie")
804                .is_none()
805        );
806    }
807}
808// LCOV_EXCL_STOP