1mod 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#[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#[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#[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 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 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 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 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#[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