Skip to main content

alibi_plugins/magic_link/
mod.rs

1//! Single-use mailbox authentication links, with native delivery callbacks.
2
3mod 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/// Delivery data. Debug omits the token, URL and arbitrary delivery metadata.
23#[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    /// Lifetime in seconds. Zero and NaN use 300 seconds. Fractions retain
80    /// JavaScript millisecond rounding; invalid dates fail before persistence.
81    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        // The query is already decoded once; 1.7.12 no longer decodes it again.
252        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        // Each pinned originCheck closure labels its input callbackURL,
257        // including the new-user and error callbacks.
258        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/// Mailbox, redirects and delivery metadata for server-side magic-link issuance.
386#[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// LCOV_EXCL_START
469#[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    // Upstream: magicLink signInMagicLink persists before delivery, then consumes
542    // once; no callback gives the complete real user/session response.
543    #[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    // Upstream: callbackURL/newUserCallbackURL choose different destinations;
625    // untrusted redirects are rejected before single-use consumption.
626    #[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    // Upstream: disabled signup still issues/delivers, but consumes and redirects
671    // with new_user_signup_disabled when the token identifies a new user.
672    #[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    // Upstream: hashed storage derives the lookup key, and verification after
715    // expiry invalidates the record without minting a user/session.
716    #[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    // Upstream: a magic-link proof removes unproven standing sessions before login.
780    #[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    // Upstream: custom token generation and storage compose, and concurrent calls
829    // consume a shared token once even when both callers know the actual secret.
830    #[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    // Golden Zod vectors and all originCheck closures are evaluated before the
890    // challenge is touched, including error/new-user callbacks.
891    #[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    // Upstream: delivery fails after persistence; an already issued token retains
948    // its deadline and is usable after the application restores the sender.
949    #[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// LCOV_EXCL_STOP