1use super::authentication_helpers::{
4 JsonField, JsonFieldKind, RequestBody, parse_body, redirect, revoke_unproven_access,
5 session_response,
6};
7use super::token_crypto::hash_token;
8use alibi_core::{
9 AuthContext, AuthError, AuthRequest, AuthResponse, AuthResult, AuthSchema, AuthUser,
10 CreateUser, CreateVerification,
11};
12use async_trait::async_trait;
13use rand::RngExt;
14use serde::{Deserialize, Serialize};
15use serde_json::json;
16use std::sync::Arc;
17use url::Url;
18
19#[derive(Clone, Serialize)]
21pub struct MagicLinkDelivery {
22 pub email: String,
23 pub url: String,
24 pub token: String,
25 #[serde(serialize_with = "alibi_core::utils::json::serialize")]
26 pub metadata: Option<alibi_core::utils::json::JsValue>,
27}
28
29impl std::fmt::Debug for MagicLinkDelivery {
30 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31 f.debug_struct("MagicLinkDelivery").finish_non_exhaustive()
32 }
33}
34
35#[async_trait]
36pub trait SendMagicLink: Send + Sync {
37 async fn send(
38 &self,
39 delivery: &MagicLinkDelivery,
40 context: &alibi_core::CallbackContext,
41 ) -> AuthResult<()>;
42}
43
44#[async_trait]
45pub trait MagicLinkTokenGenerator: Send + Sync {
46 async fn generate(&self, email: &str) -> AuthResult<String>;
47}
48
49#[async_trait]
50pub trait MagicLinkTokenHasher: Send + Sync {
51 async fn hash(&self, token: &str) -> AuthResult<String>;
52}
53
54#[derive(Clone)]
55pub enum MagicLinkTokenStorage {
56 Plain,
57 Hashed,
58 Custom(Arc<dyn MagicLinkTokenHasher>),
59}
60
61impl std::fmt::Debug for MagicLinkTokenStorage {
62 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63 match self {
64 Self::Plain => f.write_str("MagicLinkTokenStorage::Plain"),
65 Self::Hashed => f.write_str("MagicLinkTokenStorage::Hashed"),
66 Self::Custom(..) => f.write_str("MagicLinkTokenStorage::Custom"),
67 }
68 }
69}
70
71#[derive(Clone)]
72pub struct MagicLinkConfig {
73 pub send_magic_link: Option<Arc<dyn SendMagicLink>>,
74 pub generate_token: Option<Arc<dyn MagicLinkTokenGenerator>>,
75 pub storage: MagicLinkTokenStorage,
76 pub expires_in: f64,
79 pub rate_limit: alibi_core::EndpointRateLimit,
80 pub disable_sign_up: bool,
81}
82
83impl std::fmt::Debug for MagicLinkConfig {
84 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85 f.debug_struct("MagicLinkConfig").finish_non_exhaustive()
86 }
87}
88
89impl Default for MagicLinkConfig {
90 fn default() -> Self {
91 Self {
92 send_magic_link: None,
93 generate_token: None,
94 storage: MagicLinkTokenStorage::Plain,
95 expires_in: 300.0,
96 rate_limit: alibi_core::EndpointRateLimit {
97 window_seconds: 60.0,
98 max_requests: 5.0,
99 },
100 disable_sign_up: false,
101 }
102 }
103}
104
105#[derive(Clone)]
106pub struct MagicLinkPlugin {
107 config: MagicLinkConfig,
108}
109
110impl std::fmt::Debug for MagicLinkPlugin {
111 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112 f.debug_struct("MagicLinkPlugin").finish_non_exhaustive()
113 }
114}
115
116impl MagicLinkPlugin {
117 #[must_use]
118 pub const fn new(config: MagicLinkConfig) -> Self {
119 Self { config }
120 }
121
122 async fn store_token(&self, token: &str) -> AuthResult<String> {
123 match &self.config.storage {
124 MagicLinkTokenStorage::Plain => Ok(token.to_owned()),
125 MagicLinkTokenStorage::Hashed => Ok(hash_token(token)),
126 MagicLinkTokenStorage::Custom(hasher) => hasher
127 .hash(token)
128 .await
129 .map_err(crate::helpers::callback_failure),
130 }
131 }
132
133 async fn sign_in(
134 &self,
135 req: &AuthRequest,
136 ctx: &AuthContext<impl AuthSchema>,
137 ) -> AuthResult<AuthResponse> {
138 let body: SignInRequest = match parse_body(req) {
139 Ok(value) => value,
140 Err(response) => return Ok(response),
141 };
142 let token = if let Some(generator) = &self.config.generate_token {
143 generator.generate(&body.email).await?
144 } else {
145 let alphabet = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
146 let mut rng = rand::rng();
147 (0..32)
148 .filter_map(|_| alphabet.get(rng.random_range(0..alphabet.len())).copied())
149 .map(char::from)
150 .collect()
151 };
152 let stored = self.store_token(&token).await?;
153 let data = LinkData {
154 record_type: "magic-link".into(),
155 email: body.email.clone(),
156 name: body.name,
157 };
158 let Some(expires_at) =
159 super::passwordless_numeric::expires_at(self.config.expires_in, true)
160 else {
161 return Ok(AuthResponse::new(500));
162 };
163 _ = ctx
164 .verifications()
165 .create(CreateVerification {
166 identifier: format!("magic-link:{stored}"),
167 value: serde_json::to_string(&data)?,
168 expires_at,
169 })
170 .await?;
171 let mut url = Url::parse(&ctx.config.base_url)
172 .map_err(|_error| AuthError::config("Invalid base URL"))?;
173 let path = url.path().trim_end_matches('/');
174 let base_path = if path.is_empty() {
175 ctx.config.base_path.as_str()
176 } else {
177 path
178 };
179 let verify_path = format!("{}/magic-link/verify", base_path.trim_end_matches('/'));
180 url.set_path(&verify_path);
181 url.set_query(None);
182 url.set_fragment(None);
183 _ = url
184 .query_pairs_mut()
185 .append_pair("token", &token)
186 .append_pair(
187 "callbackURL",
188 body.callback_url
189 .as_deref()
190 .filter(|value| !value.is_empty())
191 .unwrap_or("/"),
192 );
193 if let Some(callback) = body.new_user_callback_url.filter(|value| !value.is_empty()) {
194 _ = url
195 .query_pairs_mut()
196 .append_pair("newUserCallbackURL", &callback);
197 }
198 if let Some(callback) = body.error_callback_url.filter(|value| !value.is_empty()) {
199 _ = url
200 .query_pairs_mut()
201 .append_pair("errorCallbackURL", &callback);
202 }
203 self.config
204 .send_magic_link
205 .as_ref()
206 .ok_or_else(|| AuthError::config("Magic link sender is not configured"))?
207 .send(
208 &MagicLinkDelivery {
209 email: body.email,
210 url: url.to_string(),
211 token,
212 metadata: body.metadata,
213 },
214 &alibi_core::CallbackContext::new(ctx, Some(req)),
215 )
216 .await
217 .map_err(crate::helpers::callback_failure)?;
218 AuthResponse::json(200, &json!({"status":true})).map_err(AuthError::from)
219 }
220
221 async fn verify(
222 &self,
223 req: &AuthRequest,
224 ctx: &AuthContext<impl AuthSchema>,
225 ) -> AuthResult<AuthResponse> {
226 let token = req.query.get("token").ok_or(AuthError::Upstream {
227 status: 400,
228 code: "VALIDATION_ERROR",
229 message: "[query.token] Invalid input: expected string, received undefined",
230 })?;
231 let callback = decode_callback(req, "callbackURL")?;
232 let error_callback = decode_callback(req, "errorCallbackURL")?;
233 let new_user_callback = decode_callback(req, "newUserCallbackURL")?;
234 for value in [&callback, &new_user_callback, &error_callback] {
237 if !ctx.config.current_origin_check_disabled()
238 && value
239 .as_deref()
240 .is_some_and(|value| !ctx.config.is_redirect_target_trusted(value))
241 {
242 return Err(AuthError::Upstream {
243 status: 403,
244 code: "INVALID_CALLBACK_URL",
245 message: "Invalid callbackURL",
246 });
247 }
248 }
249 let base = Url::parse(&ctx.config.base_url)
250 .map_err(|_error| AuthError::config("Invalid base URL"))?;
251 let callback_url = base
252 .join(callback.as_deref().unwrap_or("/"))
253 .map_err(|_error| AuthError::bad_request("Invalid callbackURL"))?;
254 let error_url = base
255 .join(error_callback.as_deref().unwrap_or(callback_url.as_str()))
256 .map_err(|_error| AuthError::bad_request("Invalid errorCallbackURL"))?;
257 let new_user_url = base
258 .join(
259 new_user_callback
260 .as_deref()
261 .unwrap_or(callback_url.as_str()),
262 )
263 .map_err(|_error| AuthError::bad_request("Invalid newUserCallbackURL"))?;
264 let stored = self.store_token(token).await?;
265 let identifier = format!("magic-link:{stored}");
266 let pending = ctx.verifications().find(&identifier).await?;
267 if pending
268 .as_ref()
269 .and_then(|row| parse_link_data(row.value().ok()?))
270 .is_none()
271 {
272 return Ok(error_redirect(error_url, "INVALID_TOKEN", None));
273 }
274 let Some(verification) = ctx.verifications().consume(&identifier).await? else {
275 return Ok(error_redirect(error_url, "INVALID_TOKEN", None));
276 };
277
278 let Some(data) = parse_link_data(verification.value()?) else {
279 return Ok(error_redirect(error_url, "INVALID_TOKEN", None));
280 };
281 let mut is_new_user = false;
282 let user = match ctx.database.get_user_by_email_record(&data.email).await? {
283 Some(user) if !user.email_verified() => {
284 match revoke_unproven_access(ctx, &user.id()).await? {
285 Some(user) => user,
286 None => return Ok(error_redirect(error_url, "user_not_found", None)),
287 }
288 }
289 Some(user) => user,
290 None if self.config.disable_sign_up => {
291 return Ok(error_redirect(error_url, "new_user_signup_disabled", None));
292 }
293 None => {
294 let mut user = CreateUser::new()
295 .with_email(data.email)
296 .with_name(data.name.unwrap_or_default())
297 .with_email_verified(true);
298 super::helpers::apply_default_role(ctx, &mut user);
299 is_new_user = true;
300 match ctx
301 .database
302 .create_user_with_source_record(
303 user,
304 alibi_core::user_validation::UserValidationSource::creation("magic-link"),
305 )
306 .await
307 {
308 Ok(user) => user,
309 Err(error) => {
310 let (_, code, message) = error.error_payload();
311 if let Some(code) = code {
312 return Ok(error_redirect(error_url, &code, Some(&message)));
313 }
314 return Err(error);
315 }
316 }
317 }
318 };
319 let (payload, mut response) = session_response(ctx, req, user).await?;
320 if callback.is_none() {
321 response.body = serde_json::to_vec(&payload)?;
322 return Ok(response);
323 }
324 let mut redirect_response = redirect(if is_new_user {
325 new_user_url.as_str()
326 } else {
327 callback_url.as_str()
328 });
329 for cookie in response.headers.get_all("set-cookie") {
330 redirect_response.headers.append("Set-Cookie", cookie);
331 }
332 Ok(redirect_response)
333 }
334}
335
336alibi_core::impl_auth_plugin! {
337 MagicLinkPlugin, "magic-link";
338 routes {
339 post "/sign-in/magic-link" => sign_in, "signInWithMagicLink";
340 get "/magic-link/verify" => verify, "verifyMagicLink";
341 }
342 extra {
343 route_openapi_metadata!(S);
344
345 fn rate_limits(&self) -> Vec<alibi_core::PluginRateLimit> {
346 vec![alibi_core::PluginRateLimit { matches: |path| path.starts_with("/sign-in/magic-link") || path.starts_with("/magic-link/verify"), limit: alibi_core::EndpointRateLimit {
347 window_seconds: if self.config.rate_limit.window_seconds == 0.0 || self.config.rate_limit.window_seconds.is_nan() { 60.0 } else { self.config.rate_limit.window_seconds },
348 max_requests: if self.config.rate_limit.max_requests == 0.0 || self.config.rate_limit.max_requests.is_nan() { 5.0 } else { self.config.rate_limit.max_requests },
349 } }]
350 }
351 async fn on_init(&self, ctx: &mut alibi_core::AuthInitContext<S>) -> AuthResult<()> {
352 ctx.set_metadata("magic-link.enabled", json!(true));
353 Ok(())
354 }
355 }
356}
357
358#[derive(Deserialize)]
359struct SignInRequest {
360 email: String,
361 name: Option<String>,
362 #[serde(rename = "callbackURL")]
363 callback_url: Option<String>,
364 #[serde(rename = "newUserCallbackURL")]
365 new_user_callback_url: Option<String>,
366 #[serde(rename = "errorCallbackURL")]
367 error_callback_url: Option<String>,
368 metadata: Option<alibi_core::utils::json::JsValue>,
369}
370
371#[derive(Deserialize, Serialize)]
372struct LinkData {
373 #[serde(rename = "type")]
374 record_type: String,
375 email: String,
376 #[serde(skip_serializing_if = "Option::is_none")]
377 name: Option<String>,
378}
379
380fn parse_link_data(value: &str) -> Option<LinkData> {
381 let value: serde_json::Value = serde_json::from_str(value).ok()?;
382 let object = value.as_object()?;
383 if object
384 .keys()
385 .any(|key| !matches!(key.as_str(), "type" | "email" | "name"))
386 || object.get("type")?.as_str()? != "magic-link"
387 || !super::authentication_helpers::is_valid_email(object.get("email")?.as_str()?)
388 || object.get("name").is_some_and(|name| !name.is_string())
389 {
390 return None;
391 }
392 serde_json::from_value(value).ok()
393}
394
395impl RequestBody for SignInRequest {
396 const FIELDS: &'static [JsonField] = &[
397 JsonField {
398 name: "email",
399 kind: JsonFieldKind::Email,
400 required: true,
401 },
402 JsonField::string("name", false),
403 JsonField::string("callbackURL", false),
404 JsonField::string("newUserCallbackURL", false),
405 JsonField::string("errorCallbackURL", false),
406 JsonField {
407 name: "metadata",
408 kind: JsonFieldKind::Record,
409 required: false,
410 },
411 ];
412}
413
414fn decode_callback(req: &AuthRequest, name: &str) -> AuthResult<Option<String>> {
415 req.query
416 .get(name)
417 .filter(|value| !value.is_empty())
418 .map(|value| {
419 let bytes = value.as_bytes();
420 for (index, byte) in bytes.iter().enumerate() {
421 if *byte == b'%'
422 && (bytes
423 .get(index + 1)
424 .is_none_or(|byte| !byte.is_ascii_hexdigit())
425 || bytes
426 .get(index + 2)
427 .is_none_or(|byte| !byte.is_ascii_hexdigit()))
428 {
429 return Err(AuthError::CallbackFailure(Box::new(AuthError::internal(
430 "Malformed callback encoding",
431 ))));
432 }
433 }
434 urlencoding::decode(value)
435 .map(std::borrow::Cow::into_owned)
436 .map_err(|error| {
437 AuthError::CallbackFailure(Box::new(AuthError::internal(error.to_string())))
438 })
439 })
440 .transpose()
441}
442
443fn error_redirect(mut url: Url, error: &str, description: Option<&str>) -> AuthResponse {
444 let pairs: Vec<(String, String)> = url
445 .query_pairs()
446 .filter(|(key, _)| key != "error" && (description.is_none() || key != "error_description"))
447 .map(|(key, value)| (key.into_owned(), value.into_owned()))
448 .collect();
449 url.set_query(None);
450 for (key, value) in pairs {
451 _ = url.query_pairs_mut().append_pair(&key, &value);
452 }
453 _ = url.query_pairs_mut().append_pair("error", error);
454 if let Some(description) = description {
455 _ = url
456 .query_pairs_mut()
457 .append_pair("error_description", description);
458 }
459 redirect(url.as_str())
460}
461
462#[cfg(test)]
464mod tests {
465 use super::*;
466 use crate::test_helpers;
467 use alibi_core::{AuthPlugin, AuthSession, AuthUser, AuthVerification, CreateUser, HttpMethod};
468 use chrono::{Duration, Utc};
469 use serde_json::Value;
470 use std::sync::Mutex;
471
472 #[derive(Default)]
473 struct Outbox(Mutex<Vec<MagicLinkDelivery>>);
474
475 #[async_trait]
476 impl SendMagicLink for Outbox {
477 async fn send(
478 &self,
479 delivery: &MagicLinkDelivery,
480 _context: &alibi_core::CallbackContext,
481 ) -> AuthResult<()> {
482 self.0.lock().unwrap().push(delivery.clone());
483 Ok(())
484 }
485 }
486
487 struct FixedToken;
488
489 #[async_trait]
490 impl MagicLinkTokenGenerator for FixedToken {
491 async fn generate(&self, _: &str) -> AuthResult<String> {
492 Ok("application-issued-link-token".into())
493 }
494 }
495
496 struct PrefixHasher;
497
498 #[async_trait]
499 impl MagicLinkTokenHasher for PrefixHasher {
500 async fn hash(&self, token: &str) -> AuthResult<String> {
501 Ok(format!("application-hash:{}", hash_token(token)))
502 }
503 }
504
505 struct FailedDelivery;
506
507 #[async_trait]
508 impl SendMagicLink for FailedDelivery {
509 async fn send(
510 &self,
511 _: &MagicLinkDelivery,
512 _context: &alibi_core::CallbackContext,
513 ) -> AuthResult<()> {
514 Err(AuthError::internal("deterministic sender outage"))
515 }
516 }
517
518 fn configured() -> (MagicLinkPlugin, Arc<Outbox>) {
519 let outbox = Arc::new(Outbox::default());
520 (
521 MagicLinkPlugin::new(MagicLinkConfig {
522 send_magic_link: Some(Arc::<Outbox>::clone(&outbox)),
523 ..Default::default()
524 }),
525 outbox,
526 )
527 }
528
529 fn verify_request(token: &str) -> AuthRequest {
530 let mut req = AuthRequest::new(HttpMethod::Get, "/magic-link/verify");
531 _ = req.query.insert("token".into(), token.into());
532 req
533 }
534
535 #[tokio::test]
538 async fn delivered_link_authenticates_its_mailbox_once_and_persists_session() {
539 let ctx = test_helpers::create_test_context().await;
540 let (plugin, outbox) = configured();
541 let req = test_helpers::create_auth_json_request_no_query(
542 HttpMethod::Post,
543 "/sign-in/magic-link",
544 None,
545 Some(
546 json!({"email":"owner@example.com","name":"Owner","metadata":{"campaign":"welcome"}}),
547 ),
548 );
549 assert_eq!(
550 plugin.on_request(&req, &ctx).await.unwrap().unwrap().status,
551 200
552 );
553 let delivery = outbox.0.lock().unwrap().last().unwrap().clone();
554 let url = Url::parse(&delivery.url).unwrap();
555 assert_eq!(url.path(), "/api/auth/magic-link/verify");
556 assert_eq!(
557 url.query_pairs()
558 .find(|(key, _)| key == "callbackURL")
559 .map(|(_, value)| value.into_owned()),
560 Some("/".into())
561 );
562 assert_eq!(
563 delivery.metadata,
564 Some(json!({"campaign":"welcome"}).into())
565 );
566 let stored = ctx
567 .database
568 .get_latest_verification_by_identifier(&format!("magic-link:{}", delivery.token))
569 .await
570 .unwrap()
571 .unwrap();
572 assert!(
573 (stored.expires_at() - Utc::now() - Duration::seconds(300))
574 .num_seconds()
575 .abs()
576 <= 1
577 );
578 let req_2 = verify_request(&delivery.token);
579 let response = plugin.on_request(&req_2, &ctx).await.unwrap().unwrap();
580 assert_eq!(response.status, 200);
581 let payload: Value = serde_json::from_slice(&response.body).unwrap();
582 let token = payload.get("token").and_then(Value::as_str).unwrap();
583 let user = ctx
584 .database
585 .get_user_by_email("owner@example.com")
586 .await
587 .unwrap()
588 .unwrap();
589 assert!(user.email_verified());
590 assert_eq!(
591 ctx.database
592 .get_session(token)
593 .await
594 .unwrap()
595 .unwrap()
596 .user_id(),
597 user.id()
598 );
599 assert!(payload.get("session").is_some());
600 assert!(
601 ctx.database
602 .get_latest_verification_by_identifier(&format!("magic-link:{}", delivery.token))
603 .await
604 .unwrap()
605 .is_none()
606 );
607 let replay = plugin.on_request(&req_2, &ctx).await.unwrap().unwrap();
608 assert_eq!(replay.status, 302);
609 assert!(
610 replay
611 .headers
612 .get("location")
613 .unwrap()
614 .contains("error=INVALID_TOKEN")
615 );
616 }
617
618 #[tokio::test]
621 async fn callback_authorization_does_not_burn_token_and_new_user_redirects() {
622 let ctx = test_helpers::create_test_context().await;
623 let (plugin, outbox) = configured();
624 let req = test_helpers::create_auth_json_request_no_query(
625 HttpMethod::Post,
626 "/sign-in/magic-link",
627 None,
628 Some(json!({"email":"new@example.com"})),
629 );
630 _ = plugin.on_request(&req, &ctx).await.unwrap();
631 let delivery = outbox.0.lock().unwrap().last().unwrap().clone();
632 let mut req_2 = verify_request(&delivery.token);
633 _ = req_2
634 .query
635 .insert("callbackURL".into(), "https://evil.example/steal".into());
636 assert_eq!(
637 plugin
638 .on_request(&req_2, &ctx)
639 .await
640 .unwrap_err()
641 .status_code(),
642 403
643 );
644 assert!(
645 ctx.database
646 .get_latest_verification_by_identifier(&format!("magic-link:{}", delivery.token))
647 .await
648 .unwrap()
649 .is_some()
650 );
651 _ = req_2.query.insert("callbackURL".into(), "/existing".into());
652 _ = req_2
653 .query
654 .insert("newUserCallbackURL".into(), "/welcome?source=magic".into());
655 let response = plugin.on_request(&req_2, &ctx).await.unwrap().unwrap();
656 assert_eq!(response.status, 302);
657 assert_eq!(
658 response.headers.get("location"),
659 Some(&"http://localhost:3000/welcome?source=magic".into())
660 );
661 assert!(response.headers.contains_key("set-cookie"));
662 }
663
664 #[tokio::test]
667 async fn disabled_signup_consumes_token_without_creating_user() {
668 let ctx = test_helpers::create_test_context().await;
669 let (mut plugin, outbox) = configured();
670 plugin.config.disable_sign_up = true;
671 let req = test_helpers::create_auth_json_request_no_query(
672 HttpMethod::Post,
673 "/sign-in/magic-link",
674 None,
675 Some(json!({"email":"disabled@example.com"})),
676 );
677 _ = plugin.on_request(&req, &ctx).await.unwrap();
678 let delivery = outbox.0.lock().unwrap().last().unwrap().clone();
679 let response = plugin
680 .on_request(&verify_request(&delivery.token), &ctx)
681 .await
682 .unwrap()
683 .unwrap();
684 assert_eq!(response.status, 302);
685 assert!(
686 response
687 .headers
688 .get("location")
689 .unwrap()
690 .contains("new_user_signup_disabled")
691 );
692 assert!(
693 ctx.database
694 .get_user_by_email("disabled@example.com")
695 .await
696 .unwrap()
697 .is_none()
698 );
699 assert!(
700 ctx.database
701 .get_latest_verification_by_identifier(&format!("magic-link:{}", delivery.token))
702 .await
703 .unwrap()
704 .is_none()
705 );
706 }
707
708 #[tokio::test]
711 async fn hashed_expired_token_is_removed_and_cannot_create_session() {
712 let ctx = test_helpers::create_test_context().await;
713 let (mut plugin, outbox) = configured();
714 plugin.config.storage = MagicLinkTokenStorage::Hashed;
715 plugin.config.expires_in = -1.0;
716 let req = test_helpers::create_auth_json_request_no_query(
717 HttpMethod::Post,
718 "/sign-in/magic-link",
719 None,
720 Some(json!({"email":"expired@example.com"})),
721 );
722 _ = plugin.on_request(&req, &ctx).await.unwrap();
723 let delivery = outbox.0.lock().unwrap().last().unwrap().clone();
724 assert!(
725 ctx.database
726 .get_latest_verification_by_identifier(&format!("magic-link:{}", delivery.token))
727 .await
728 .unwrap()
729 .is_none()
730 );
731 assert!(
732 ctx.database
733 .get_latest_verification_by_identifier(&format!(
734 "magic-link:{}",
735 hash_token(&delivery.token)
736 ))
737 .await
738 .unwrap()
739 .is_some()
740 );
741 let response = plugin
742 .on_request(&verify_request(&delivery.token), &ctx)
743 .await
744 .unwrap()
745 .unwrap();
746 assert_eq!(response.status, 302);
747 assert!(
748 response
749 .headers
750 .get("location")
751 .unwrap()
752 .contains("INVALID_TOKEN")
753 );
754 assert!(
755 ctx.database
756 .get_user_by_email("expired@example.com")
757 .await
758 .unwrap()
759 .is_none()
760 );
761 assert!(
762 ctx.database
763 .get_latest_verification_by_identifier(&format!(
764 "magic-link:{}",
765 hash_token(&delivery.token)
766 ))
767 .await
768 .unwrap()
769 .is_none()
770 );
771 }
772
773 #[tokio::test]
775 async fn existing_unverified_user_keeps_identity_but_loses_previous_session() {
776 let ctx = test_helpers::create_test_context().await;
777 let user = ctx
778 .database
779 .create_user(CreateUser::new().with_email("promote@example.com"))
780 .await
781 .unwrap();
782 let previous = ctx
783 .session_manager()
784 .create_session(&user, None, None)
785 .await
786 .unwrap();
787 let (plugin, outbox) = configured();
788 let req = test_helpers::create_auth_json_request_no_query(
789 HttpMethod::Post,
790 "/sign-in/magic-link",
791 None,
792 Some(json!({"email":"promote@example.com"})),
793 );
794 _ = plugin.on_request(&req, &ctx).await.unwrap();
795 let delivery = outbox.0.lock().unwrap().last().unwrap().clone();
796 assert_eq!(
797 plugin
798 .on_request(&verify_request(&delivery.token), &ctx)
799 .await
800 .unwrap()
801 .unwrap()
802 .status,
803 200
804 );
805 let promoted = ctx
806 .database
807 .get_user_by_email("promote@example.com")
808 .await
809 .unwrap()
810 .unwrap();
811 assert_eq!(promoted.id(), user.id());
812 assert!(promoted.email_verified());
813 assert!(
814 ctx.database
815 .get_session(previous.token())
816 .await
817 .unwrap()
818 .is_none()
819 );
820 }
821
822 #[tokio::test]
825 async fn custom_generation_hashing_and_concurrent_consumption_preserve_owned_session() {
826 let ctx = test_helpers::create_test_context().await;
827 let (mut plugin, outbox) = configured();
828 plugin.config.generate_token = Some(Arc::new(FixedToken));
829 plugin.config.storage = MagicLinkTokenStorage::Custom(Arc::new(PrefixHasher));
830 let req = test_helpers::create_auth_json_request_no_query(
831 HttpMethod::Post,
832 "/sign-in/magic-link",
833 None,
834 Some(json!({"email":"custom@example.com"})),
835 );
836 _ = plugin.on_request(&req, &ctx).await.unwrap();
837 let token = outbox.0.lock().unwrap().last().unwrap().token.clone();
838 assert_eq!(token, "application-issued-link-token");
839 assert!(
840 ctx.database
841 .get_latest_verification_by_identifier(&format!("magic-link:{token}"))
842 .await
843 .unwrap()
844 .is_none()
845 );
846 assert!(
847 ctx.database
848 .get_latest_verification_by_identifier(&format!(
849 "magic-link:application-hash:{}",
850 hash_token(&token)
851 ))
852 .await
853 .unwrap()
854 .is_some()
855 );
856 let req_2 = verify_request(&token);
857 let (first, second) = tokio::join!(
858 plugin.on_request(&req_2, &ctx),
859 plugin.on_request(&req_2, &ctx)
860 );
861 let mut statuses = [
862 first.unwrap().unwrap().status,
863 second.unwrap().unwrap().status,
864 ];
865 statuses.sort_unstable();
866 assert_eq!(statuses, [200, 302]);
867 let user = ctx
868 .database
869 .get_user_by_email("custom@example.com")
870 .await
871 .unwrap()
872 .unwrap();
873 assert_eq!(
874 ctx.database
875 .get_user_sessions(&user.id())
876 .await
877 .unwrap()
878 .len(),
879 1
880 );
881 }
882
883 #[tokio::test]
886 async fn validation_and_every_callback_guard_leave_the_challenge_untouched() {
887 let ctx = test_helpers::create_test_context().await;
888 let (plugin, outbox) = configured();
889 let req = test_helpers::create_auth_json_request_no_query(
890 HttpMethod::Post,
891 "/sign-in/magic-link",
892 None,
893 Some(json!({"email":"ok@example.com","name":null,"metadata":[]})),
894 );
895 let response = plugin.on_request(&req, &ctx).await.unwrap().unwrap();
896 assert_eq!(response.status, 400);
897 let payload: Value = serde_json::from_slice(&response.body).unwrap();
898 assert_eq!(
899 payload,
900 json!({"code":"VALIDATION_ERROR","message":"[body.name] Invalid input: expected string, received null; [body.metadata] Invalid input: expected record, received array"})
901 );
902 assert!(outbox.0.lock().unwrap().is_empty());
903 let req_2 = test_helpers::create_auth_json_request_no_query(
904 HttpMethod::Post,
905 "/sign-in/magic-link",
906 None,
907 Some(json!({"email":"ok@example.com"})),
908 );
909 _ = plugin.on_request(&req_2, &ctx).await.unwrap();
910 let token = outbox.0.lock().unwrap().last().unwrap().token.clone();
911 for field in ["callbackURL", "newUserCallbackURL", "errorCallbackURL"] {
912 let mut req_3 = verify_request(&token);
913 _ = req_3
914 .query
915 .insert(field.into(), "https://foreign.example/steal".into());
916 let error = plugin.on_request(&req_3, &ctx).await.unwrap_err();
917 let (_, code, message) = error.error_payload();
918 assert_eq!(code.as_deref(), Some("INVALID_CALLBACK_URL"));
919 assert_eq!(message, "Invalid callbackURL");
920 assert!(
921 ctx.database
922 .get_latest_verification_by_identifier(&format!("magic-link:{token}"))
923 .await
924 .unwrap()
925 .is_some()
926 );
927 }
928 let missing = AuthRequest::new(HttpMethod::Get, "/magic-link/verify");
929 assert_eq!(
930 plugin
931 .on_request(&missing, &ctx)
932 .await
933 .unwrap_err()
934 .error_payload()
935 .1
936 .as_deref(),
937 Some("VALIDATION_ERROR")
938 );
939 }
940
941 #[tokio::test]
944 async fn sender_failure_keeps_token_and_error_redirect_preserves_query_state() {
945 let ctx = test_helpers::create_test_context().await;
946 let (mut plugin, _) = configured();
947 plugin.config.generate_token = Some(Arc::new(FixedToken));
948 plugin.config.send_magic_link = Some(Arc::new(FailedDelivery));
949 let req = test_helpers::create_auth_json_request_no_query(
950 HttpMethod::Post,
951 "/sign-in/magic-link",
952 None,
953 Some(json!({"email":"outage@example.com"})),
954 );
955 assert_eq!(
956 plugin
957 .on_request(&req, &ctx)
958 .await
959 .unwrap_err()
960 .status_code(),
961 500
962 );
963 assert!(
964 ctx.database
965 .get_latest_verification_by_identifier("magic-link:application-issued-link-token")
966 .await
967 .unwrap()
968 .is_some()
969 );
970 let req_2 = verify_request("application-issued-link-token");
971 assert_eq!(
972 plugin
973 .on_request(&req_2, &ctx)
974 .await
975 .unwrap()
976 .unwrap()
977 .status,
978 200
979 );
980 let mut replay = req_2;
981 _ = replay.query.insert(
982 "errorCallbackURL".into(),
983 "/error?source=magic&error=old&error_description=preserved".into(),
984 );
985 let response = plugin.on_request(&replay, &ctx).await.unwrap().unwrap();
986 let url = Url::parse(response.headers.get("location").unwrap()).unwrap();
987 assert_eq!(
988 url.query_pairs()
989 .find(|(key, _)| key == "error_description")
990 .map(|(_, value)| value.into_owned()),
991 Some("preserved".into())
992 );
993 assert_eq!(
994 url.query_pairs()
995 .find(|(key, _)| key == "error")
996 .map(|(_, value)| value.into_owned()),
997 Some("INVALID_TOKEN".into())
998 );
999 }
1000}
1001