1mod 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#[derive(Clone, Debug, Serialize, Deserialize)]
26pub struct OneTimeTokenSession {
27 pub session: SessionView,
28 pub user: UserView,
29}
30
31type ConsumedSession<S> = Result<
33 (
34 <S as AuthSchema>::User,
35 alibi_core::AdapterRecord<<S as AuthSchema>::Session>,
36 ),
37 &'static str,
38>;
39
40#[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#[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 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 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 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 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#[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