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