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