Skip to main content

alibi_plugins/magic_link/
endpoint.rs

1use super::{MagicLinkPlugin, MagicLinkSignInInput};
2use crate::authentication_helpers::{JsonField, RequestBody};
3use crate::endpoint::{definition, validate_fields, validation};
4use alibi_core::endpoint::{
5    EndpointCall, EndpointDefinition, EndpointInput, EndpointResponse, ServerEndpoint,
6};
7use alibi_core::{AuthContext, AuthResult, AuthSchema, HttpMethod};
8use serde::{Deserialize, Serialize};
9
10/// A persisted and delivered mailbox challenge, without browser authority.
11#[derive(Debug, Deserialize)]
12pub struct MagicLinkSent {
13    pub status: bool,
14}
15
16/// Single-use proof and optional verification redirects.
17#[derive(Debug, Deserialize, Serialize)]
18pub struct MagicLinkVerifyInput {
19    pub token: String,
20    #[serde(rename = "callbackURL", skip_serializing_if = "Option::is_none")]
21    pub callback_url: Option<String>,
22    #[serde(rename = "newUserCallbackURL", skip_serializing_if = "Option::is_none")]
23    pub new_user_callback_url: Option<String>,
24    #[serde(rename = "errorCallbackURL", skip_serializing_if = "Option::is_none")]
25    pub error_callback_url: Option<String>,
26}
27
28/// Browser authority issued after successful proof redemption without a redirect.
29#[derive(Debug, Deserialize)]
30pub struct MagicLinkVerified {
31    pub token: String,
32    pub user: Box<alibi_core::UserView>,
33    pub session: Box<alibi_core::wire::SessionView>,
34}
35
36const VERIFY_FIELDS: &[JsonField] = &[
37    JsonField::string("token", true),
38    JsonField::string("callbackURL", false),
39    JsonField::string("errorCallbackURL", false),
40    JsonField::string("newUserCallbackURL", false),
41];
42
43pub(super) fn definitions() -> Vec<EndpointDefinition> {
44    vec![
45        definition(
46            "signInMagicLink",
47            "signInWithMagicLink",
48            Some("/sign-in/magic-link"),
49            HttpMethod::Post,
50        ),
51        definition(
52            "magicLinkVerify",
53            "verifyMagicLink",
54            Some("/magic-link/verify"),
55            HttpMethod::Get,
56        ),
57    ]
58}
59
60pub(super) fn validate(call: &EndpointCall) -> AuthResult<EndpointInput> {
61    let input = if call.operation_id() == "verifyMagicLink" {
62        EndpointInput {
63            body: call.body().cloned(),
64            query: Some(validate_fields(call.query(), "query", VERIFY_FIELDS)?),
65        }
66    } else {
67        EndpointInput {
68            body: Some(validate_fields(
69                call.body(),
70                "body",
71                MagicLinkSignInInput::FIELDS,
72            )?),
73            query: call.query().cloned(),
74        }
75    };
76    if call.headers().is_none() {
77        return Err(validation("Headers is required"));
78    }
79    Ok(input)
80}
81
82impl MagicLinkPlugin {
83    /// Redeem a delivered proof through installed server endpoint hooks.
84    ///
85    /// Redirects return the endpoint's intentional API error with Location and cookies.
86    /// # Errors
87    /// Returns an error if the supplied query cannot be serialized.
88    pub fn verify_endpoint(
89        query: &MagicLinkVerifyInput,
90    ) -> AuthResult<ServerEndpoint<MagicLinkVerified>> {
91        ServerEndpoint::new("magic-link", "magicLinkVerify").with_query(query)
92    }
93
94    /// Request a mailbox challenge through installed server endpoint hooks.
95    ///
96    /// # Errors
97    /// Returns an error if the supplied input cannot be serialized.
98    pub fn sign_in_endpoint(
99        input: &MagicLinkSignInInput,
100    ) -> AuthResult<ServerEndpoint<MagicLinkSent>> {
101        ServerEndpoint::new("magic-link", "signInMagicLink").with_body(input)
102    }
103
104    pub(super) async fn call_endpoint(
105        &self,
106        call: &EndpointCall,
107        ctx: &AuthContext<impl AuthSchema>,
108    ) -> AuthResult<EndpointResponse> {
109        let output = if call.operation_id() == "verifyMagicLink" {
110            self.verify_core(call, ctx, call.query_as()?).await?
111        } else {
112            if let Some(request) = call.request() {
113                alibi_core::middleware::CsrfMiddleware::new(
114                    alibi_core::middleware::CsrfConfig::new(),
115                    ctx.config.clone(),
116                )
117                .check_login_origin(request)?;
118            }
119            self.sign_in_core(call.request(), ctx, call.body_as()?)
120                .await?
121        };
122        if output.status == 302 {
123            let mut response = EndpointResponse::redirect(
124                output.headers.get("location").cloned().unwrap_or_default(),
125            );
126            response.merge_headers(output.headers);
127            return Ok(response);
128        }
129        let mut response = if output.status >= 400 {
130            EndpointResponse::error(crate::endpoint::error_response(output.clone()))
131                .without_error_body()
132        } else {
133            EndpointResponse::value(alibi_core::utils::json::from_slice(&output.body)?)
134        };
135        response.set_status(Some(output.status));
136        response.merge_headers(output.headers);
137        Ok(response)
138    }
139}