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