Skip to main content

alibi_plugins/magic_link/
mod.rs

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