Skip to main content

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