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 server_endpoints(&self) -> Vec<alibi_core::endpoint::EndpointDefinition> {
326        endpoint::definitions()
327    }
328
329    fn validate_endpoint(
330        &self,
331        call: &alibi_core::endpoint::EndpointCall,
332        _ctx: &AuthContext<S>,
333    ) -> AuthResult<alibi_core::endpoint::EndpointInput> {
334        endpoint::validate(call)
335    }
336
337    async fn on_endpoint(
338        &self,
339        call: &alibi_core::endpoint::EndpointCall,
340        ctx: &AuthContext<S>,
341    ) -> AuthResult<alibi_core::endpoint::EndpointResponse> {
342        self.call_endpoint(call, ctx).await
343    }
344
345    async fn on_request(
346        &self,
347        req: &AuthRequest,
348        ctx: &AuthContext<S>,
349    ) -> AuthResult<Option<AuthResponse>> {
350        let response = match (req.method(), req.path()) {
351            (HttpMethod::Get, "/one-time-token/generate") => self.generate(req, ctx).await?,
352            (HttpMethod::Post, "/one-time-token/verify") => self.verify(req, ctx).await?,
353            _ => return Ok(None),
354        };
355        Ok(Some(response))
356    }
357    async fn after_request(
358        &self,
359        req: &AuthRequest,
360        ctx: &AuthContext<S>,
361        mut response: AuthResponse,
362    ) -> AuthResult<AuthResponse> {
363        if !self.config.set_ott_header_on_new_session {
364            return Ok(response);
365        }
366        if let Some(session) = response_session(ctx, &response).await? {
367            let token = self
368                .generate_for_session(
369                    &OneTimeTokenSession {
370                        session: ctx.session_view(&session.session),
371                        user: ctx.user_view(&session.user),
372                    },
373                    Some(req),
374                    ctx,
375                )
376                .await?;
377            let mut expose = response
378                .headers
379                .get("access-control-expose-headers")
380                .into_iter()
381                .flat_map(|value| value.split(','))
382                .map(str::trim)
383                .filter(|value| !value.is_empty())
384                .fold(Vec::<String>::new(), |mut headers, value| {
385                    if !headers.iter().any(|header| header == value) {
386                        headers.push(value.to_owned());
387                    }
388                    headers
389                });
390            if !expose.iter().any(|header| header == "set-ott") {
391                expose.push("set-ott".to_owned());
392            }
393            _ = response.headers.insert("set-ott", token);
394            _ = response
395                .headers
396                .insert("access-control-expose-headers", expose.join(", "));
397        }
398        Ok(response)
399    }
400}
401
402fn message_response(status: u16, message: &str) -> AuthResult<AuthResponse> {
403    Ok(AuthResponse::json(status, &json!({ "message": message }))?)
404}
405
406fn random_token() -> String {
407    const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ-_";
408    (0..32)
409        .map(|_| char::from(ALPHABET.choose(&mut rand::rng()).copied().unwrap_or(b'a')))
410        .collect()
411}
412
413// LCOV_EXCL_START
414#[cfg(test)]
415mod tests {
416    #![allow(
417        clippy::unwrap_used,
418        reason = "asserted one-time-token contracts against real SQLite"
419    )]
420
421    use super::*;
422    use crate::test_helpers;
423    use alibi_core::CreateUser;
424    use alibi_core::utils::cookie_utils::{
425        related_cookie_name, sign_cookie_value, verify_cookie_value,
426    };
427
428    type TestSchema = alibi_seaorm::store::__private_test_support::bundled_schema::BundledSchema;
429
430    struct CustomHasher;
431
432    #[async_trait]
433    impl HashOneTimeToken for CustomHasher {
434        async fn hash(&self, token: &str) -> AuthResult<String> {
435            Ok(format!("custom:{token}"))
436        }
437    }
438
439    struct CustomGenerator;
440
441    #[async_trait]
442    impl GenerateOneTimeToken for CustomGenerator {
443        async fn generate(
444            &self,
445            session: &OneTimeTokenSession,
446            request: Option<&AuthRequest>,
447        ) -> AuthResult<String> {
448            assert_eq!(request.map(AuthRequest::path), None);
449            assert_ne!(session.user.id.len(), 0);
450            Ok("custom-generated-token".to_owned())
451        }
452    }
453
454    async fn setup() -> (AuthContext<TestSchema>, OneTimeTokenSession) {
455        let ctx = test_helpers::create_test_context().await;
456        let (user, session) = test_helpers::create_user_and_session(
457            &ctx,
458            CreateUser::new().with_email("one-time-token@fixture.test"),
459            Duration::days(1),
460        )
461        .await;
462        (ctx, OneTimeTokenSession { session, user })
463    }
464
465    fn signed_request(
466        path: &str,
467        session: &OneTimeTokenSession,
468        ctx: &AuthContext<impl AuthSchema>,
469    ) -> AuthRequest {
470        let mut req = test_helpers::create_auth_request_no_query(HttpMethod::Get, path, None, None);
471        req.headers.insert(
472            "cookie".to_owned(),
473            alibi_core::utils::cookie_utils::create_session_cookie(
474                &session.session.token,
475                &ctx.config,
476            )
477            .unwrap()
478            .split(';')
479            .next()
480            .unwrap()
481            .to_owned(),
482        );
483        req
484    }
485
486    #[tokio::test]
487    async fn default_tokens_are_persisted_consumed_once_and_reuse_the_original_session() {
488        let (ctx, session) = setup().await;
489        let plugin = OneTimeTokenPlugin::new();
490        let token = plugin
491            .generate_for_session(&session, None, &ctx)
492            .await
493            .unwrap();
494        assert_eq!(token.chars().count(), 32);
495        let identifier = format!("one-time-token:{token}");
496        let stored = ctx
497            .database
498            .get_latest_verification_by_identifier(&identifier)
499            .await
500            .unwrap()
501            .unwrap();
502        assert_eq!(stored.value, session.session.token);
503        assert!((stored.expires_at - Utc::now()).num_seconds() >= 179);
504        let verified = plugin.verify_token(&token, &ctx).await.unwrap();
505        assert_eq!(verified.session.token, session.session.token);
506        assert_eq!(verified.user.id, session.user.id);
507        assert!(
508            ctx.database
509                .get_latest_verification_by_identifier(&identifier)
510                .await
511                .unwrap()
512                .is_none()
513        );
514        assert!(
515            matches!(plugin.verify_token(&token, &ctx).await, Err(AuthError::BadRequest(message)) if message == "Invalid token")
516        );
517        assert_eq!(
518            ctx.database
519                .get_user_sessions(&session.user.id)
520                .await
521                .unwrap()
522                .len(),
523            1
524        );
525    }
526
527    #[tokio::test]
528    async fn expired_latest_generation_invalidates_older_live_token_and_revoked_session_consumes_the_token()
529     {
530        let (ctx, session) = setup().await;
531        let plugin = OneTimeTokenPlugin::new();
532        let identifier = "one-time-token:duplicate";
533        ctx.database
534            .create_verification(CreateVerification {
535                identifier: identifier.to_owned(),
536                value: session.session.token.clone(),
537                expires_at: Utc::now() + Duration::minutes(3),
538            })
539            .await
540            .unwrap();
541        ctx.database
542            .create_verification(CreateVerification {
543                identifier: identifier.to_owned(),
544                value: session.session.token.clone(),
545                expires_at: Utc::now() - Duration::seconds(1),
546            })
547            .await
548            .unwrap();
549        assert!(
550            matches!(plugin.verify_token("duplicate", &ctx).await, Err(AuthError::BadRequest(message)) if message == "Invalid token")
551        );
552        assert!(
553            ctx.database
554                .get_latest_verification_by_identifier(identifier)
555                .await
556                .unwrap()
557                .is_none()
558        );
559        let token = plugin
560            .generate_for_session(&session, None, &ctx)
561            .await
562            .unwrap();
563        ctx.database
564            .delete_session(&session.session.token)
565            .await
566            .unwrap();
567        assert!(
568            matches!(plugin.verify_token(&token, &ctx).await, Err(AuthError::BadRequest(message)) if message == "Session not found")
569        );
570        assert!(
571            matches!(plugin.verify_token(&token, &ctx).await, Err(AuthError::BadRequest(message)) if message == "Invalid token")
572        );
573    }
574
575    #[tokio::test]
576    async fn concurrent_token_redemption_has_exactly_one_winner() {
577        let (ctx, session) = setup().await;
578        let plugin = OneTimeTokenPlugin::new();
579        let token = plugin
580            .generate_for_session(&session, None, &ctx)
581            .await
582            .unwrap();
583        let (first, second) = tokio::join!(
584            plugin.verify_token(&token, &ctx),
585            plugin.verify_token(&token, &ctx)
586        );
587        assert_ne!(first.is_ok(), second.is_ok());
588        let verified = first.or(second).unwrap();
589        assert_eq!(verified.session.token, session.session.token);
590        assert_eq!(
591            ctx.database
592                .get_user_sessions(&session.user.id)
593                .await
594                .unwrap()
595                .len(),
596            1
597        );
598    }
599
600    #[tokio::test]
601    async fn hashed_and_custom_storage_share_consistent_issue_and_consume_paths() {
602        let (ctx, session) = setup().await;
603        for storage in [
604            OneTimeTokenStorage::Hashed,
605            OneTimeTokenStorage::Custom(Arc::new(CustomHasher)),
606        ] {
607            let plugin = OneTimeTokenPlugin::with_config(OneTimeTokenConfig {
608                storage,
609                generator: Some(Arc::new(CustomGenerator)),
610                ..Default::default()
611            });
612            let token = plugin
613                .generate_for_session(&session, None, &ctx)
614                .await
615                .unwrap();
616            let stored = plugin.stored_token(&token).await.unwrap();
617            assert_ne!(token, stored);
618            assert!(
619                ctx.database
620                    .get_latest_verification_by_identifier(&format!("one-time-token:{token}"))
621                    .await
622                    .unwrap()
623                    .is_none()
624            );
625            let row = ctx
626                .database
627                .get_latest_verification_by_identifier(&format!("one-time-token:{stored}"))
628                .await
629                .unwrap()
630                .unwrap();
631            assert_eq!(row.value, session.session.token);
632            assert_eq!(
633                plugin
634                    .verify_token(&token, &ctx)
635                    .await
636                    .unwrap()
637                    .session
638                    .token,
639                session.session.token
640            );
641        }
642    }
643
644    #[tokio::test]
645    #[expect(
646        clippy::too_many_lines,
647        reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
648    )]
649    async fn http_transfer_cookie_headers_and_server_only_configuration_are_observable() {
650        let (ctx, session) = setup().await;
651        let plugin = OneTimeTokenPlugin::with_config(OneTimeTokenConfig {
652            disable_client_request: true,
653            set_ott_header_on_new_session: true,
654            ..Default::default()
655        });
656        let req = signed_request("/one-time-token/generate", &session, &ctx);
657        assert_eq!(plugin.generate(&req, &ctx).await.unwrap().status, 400);
658        let token = plugin
659            .generate_for_session(&session, None, &ctx)
660            .await
661            .unwrap();
662        let verify = test_helpers::create_auth_json_request_no_query(
663            HttpMethod::Post,
664            "/one-time-token/verify",
665            None,
666            Some(json!({ "token": token })),
667        );
668        let response = plugin.verify(&verify, &ctx).await.unwrap();
669        assert_eq!(response.status, 200);
670        let cookie = response.headers.get("set-cookie").unwrap();
671        let cookie = cookie::Cookie::parse(cookie.clone()).unwrap();
672        assert_eq!(
673            verify_cookie_value(cookie.value(), &ctx.config.secret).as_deref(),
674            Some(session.session.token.as_str())
675        );
676        for (preference, persistent) in [
677            (None, true),
678            (Some(""), true),
679            (Some("false"), false),
680            (Some("true"), false),
681        ] {
682            let token_2 = plugin
683                .generate_for_session(&session, None, &ctx)
684                .await
685                .unwrap();
686            let mut verify_2 = test_helpers::create_auth_json_request_no_query(
687                HttpMethod::Post,
688                "/one-time-token/verify",
689                None,
690                Some(json!({ "token": token_2 })),
691            );
692            if let Some(value) = preference {
693                verify_2.headers.insert(
694                    "cookie".to_owned(),
695                    format!(
696                        "{}={}",
697                        related_cookie_name(&ctx.config, "dont_remember"),
698                        sign_cookie_value(value, &ctx.config.secret)
699                    ),
700                );
701            }
702            let verified = plugin.verify(&verify_2, &ctx).await.unwrap();
703            assert_eq!(verified.status, 200);
704            let cookies = verified
705                .headers
706                .get_all("set-cookie")
707                .map(|header| cookie::Cookie::parse(header.clone()).unwrap())
708                .collect::<Vec<_>>();
709            let session_cookie = cookies
710                .iter()
711                .find(|cookie_2| cookie_2.name() == ctx.config.session.cookie_name)
712                .unwrap();
713            assert_eq!(
714                cookies.iter().any(|candidate| candidate.name()
715                    == related_cookie_name(&ctx.config, "dont_remember")),
716                !persistent
717            );
718            assert_eq!(
719                session_cookie
720                    .max_age()
721                    .map(cookie::time::Duration::whole_seconds),
722                persistent.then_some(ctx.config.session.expires_in.num_seconds()),
723                "receiving preference {preference:?}"
724            );
725            assert_eq!(
726                verify_cookie_value(session_cookie.value(), &ctx.config.secret).as_deref(),
727                Some(session.session.token.as_str())
728            );
729        }
730        let mut response = response;
731        response.headers.insert(
732            "access-control-expose-headers",
733            " existing, ,existing, set-ott, set-ott, Existing ",
734        );
735        let hooked = plugin.after_request(&verify, &ctx, response).await.unwrap();
736        assert_eq!(
737            hooked.headers.get("access-control-expose-headers").unwrap(),
738            "existing, set-ott, Existing"
739        );
740        let delivered = plugin
741            .verify_token(hooked.headers.get("set-ott").unwrap(), &ctx)
742            .await
743            .unwrap();
744        assert_eq!(delivered.session.token, session.session.token);
745        assert_eq!(delivered.user.id, session.user.id);
746        let no_cookie = OneTimeTokenPlugin::with_config(OneTimeTokenConfig {
747            disable_set_session_cookie: true,
748            ..Default::default()
749        });
750        let token_3 = no_cookie
751            .generate_for_session(&session, None, &ctx)
752            .await
753            .unwrap();
754        let verify_3 = test_helpers::create_auth_json_request_no_query(
755            HttpMethod::Post,
756            "/one-time-token/verify",
757            None,
758            Some(json!({ "token": token_3 })),
759        );
760        assert!(
761            no_cookie
762                .verify(&verify_3, &ctx)
763                .await
764                .unwrap()
765                .headers
766                .get("set-cookie")
767                .is_none()
768        );
769    }
770}
771// LCOV_EXCL_STOP