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