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