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