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