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