Skip to main content

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