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