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