Skip to main content

alibi_api/plugins/one_time_token/
endpoint.rs

1use super::{OneTimeTokenPlugin, OneTimeTokenSession};
2use crate::plugins::authentication_helpers::JsonField;
3use crate::plugins::endpoint::{definition, validate_fields};
4use alibi_core::HttpMethod;
5use alibi_core::endpoint::{
6    EndpointCall, EndpointDefinition, EndpointInput, EndpointResponse, ServerEndpoint,
7};
8use alibi_core::session::SessionRequest;
9use alibi_core::utils::cookie_utils::{
10    related_cookie_name, sign_cookie_value, verify_cookie_value,
11};
12use alibi_core::utils::json::JsValue;
13use alibi_core::{AuthContext, AuthError, AuthResult, AuthSchema};
14use chrono::Utc;
15use serde::Deserialize;
16
17#[derive(Debug, Deserialize)]
18pub struct OneTimeTokenOutput {
19    pub token: String,
20}
21
22pub(super) fn definitions() -> Vec<EndpointDefinition> {
23    vec![
24        definition(
25            "generateOneTimeToken",
26            "generateOneTimeToken",
27            Some("/one-time-token/generate"),
28            HttpMethod::Get,
29        ),
30        definition(
31            "verifyOneTimeToken",
32            "verifyOneTimeToken",
33            Some("/one-time-token/verify"),
34            HttpMethod::Post,
35        ),
36    ]
37}
38
39pub(super) fn validate(call: &EndpointCall) -> AuthResult<EndpointInput> {
40    Ok(EndpointInput {
41        body: if call.operation_id() == "verifyOneTimeToken" {
42            Some(validate_fields(
43                call.body(),
44                "body",
45                &[JsonField::string("token", true)],
46            )?)
47        } else {
48            call.body().cloned()
49        },
50        query: call.query().cloned(),
51    })
52}
53
54impl OneTimeTokenPlugin {
55    /// Generate from genuine credentials through installed middleware and hooks.
56    #[must_use]
57    pub const fn generate_endpoint() -> ServerEndpoint<OneTimeTokenOutput> {
58        ServerEndpoint::new("one-time-token", "generateOneTimeToken")
59    }
60
61    /// Consume a token through the registered endpoint, including cookie publication.
62    #[must_use]
63    pub fn verify_endpoint(token: impl Into<String>) -> ServerEndpoint<OneTimeTokenSession> {
64        ServerEndpoint::new("one-time-token", "verifyOneTimeToken").with_body_value(
65            JsValue::Object(
66                [("token".into(), JsValue::String(token.into()))]
67                    .into_iter()
68                    .collect(),
69            ),
70        )
71    }
72
73    pub(super) async fn call_endpoint<S: AuthSchema>(
74        &self,
75        call: &EndpointCall,
76        ctx: &AuthContext<S>,
77    ) -> AuthResult<EndpointResponse> {
78        if call.operation_id() == "generateOneTimeToken" {
79            let (user, session) = ctx.require_cached_session(call).await.map_err(|error| {
80                if matches!(error, AuthError::Unauthenticated) {
81                    super::unauthorized()
82                } else {
83                    error
84                }
85            })?;
86            let user = match &user {
87                alibi_core::AuthenticatedUser::Stored(user) => ctx.user_view(user),
88                alibi_core::AuthenticatedUser::Cached(user) => (**user).clone(),
89            };
90            let session = OneTimeTokenSession {
91                user,
92                session: call.virtual_session().unwrap_or(session),
93            };
94            call.record_authenticated_session(session.user.clone(), session.session.clone());
95            if self.config.disable_client_request && call.request().is_some() {
96                return Err(AuthError::Api {
97                    status: 400,
98                    code: None,
99                    message: "Client requests are disabled".into(),
100                });
101            }
102            EndpointResponse::json(
103                &serde_json::json!({"token": self.generate_for_session(&session, call.request(), ctx).await?}),
104            )
105        } else {
106            #[derive(Deserialize)]
107            struct Body {
108                token: String,
109            }
110            let body: Body = call.body_as()?;
111            let (user, stored_session) = match self.consume_stored_session(&body.token, ctx).await?
112            {
113                super::TokenSessionLookup::Found { user, session } => (user, session),
114                super::TokenSessionLookup::Missing(absence) => {
115                    return Err(AuthError::Api {
116                        status: 400,
117                        code: None,
118                        message: absence.message().into(),
119                    });
120                }
121            };
122            let session = OneTimeTokenSession {
123                user: ctx.user_view(&user),
124                session: ctx.session_view(&stored_session),
125            };
126            if !self.config.disable_set_session_cookie {
127                let dont_remember = call
128                    .session_headers()
129                    .get("cookie")
130                    .and_then(|headers| {
131                        cookie::Cookie::split_parse(headers)
132                            .flatten()
133                            .find(|cookie| {
134                                cookie.name() == related_cookie_name(&ctx.config, "dont_remember")
135                            })
136                            .map(|cookie| cookie.value().to_owned())
137                    })
138                    .and_then(|value| verify_cookie_value(&value, ctx.config.current_secret()))
139                    .is_some_and(|value| !value.is_empty());
140                // The endpoint owns its actual token/preference cookies. Cache
141                // publication adds its own envelope after successful encoding.
142                // Use the canonical serializer without an Expires attribute.
143                {
144                    #[expect(
145                        clippy::as_conversions,
146                        clippy::cast_precision_loss,
147                        reason = "Cookie Max-Age uses JavaScript Number seconds at the genuine publication boundary"
148                    )]
149                    let max_age = (!dont_remember)
150                        .then(|| ctx.config.session.expires_in.num_seconds() as f64);
151                    call.queue_response_header(
152                        "set-cookie",
153                        alibi_core::session::cookie_cache::cookie_header(
154                            &ctx.config.session.cookie_name,
155                            &urlencoding::decode(&sign_cookie_value(
156                                &session.session.token,
157                                ctx.config.current_secret(),
158                            ))
159                            .map_err(|error| AuthError::internal(error.to_string()))?,
160                            max_age,
161                            &ctx.config,
162                        )?,
163                    );
164                    if dont_remember {
165                        call.queue_response_header(
166                            "set-cookie",
167                            alibi_core::session::cookie_cache::cookie_header(
168                                &related_cookie_name(&ctx.config, "dont_remember"),
169                                &urlencoding::decode(&sign_cookie_value(
170                                    "true",
171                                    ctx.config.current_secret(),
172                                ))
173                                .map_err(|error| AuthError::internal(error.to_string()))?,
174                                None,
175                                &ctx.config,
176                            )?,
177                        );
178                    }
179                }
180                alibi_core::session::cookie_cache::runtime::emit_issuance(
181                    ctx,
182                    &user,
183                    &stored_session,
184                )
185                .await?;
186            }
187            if session.session.expires_at < Utc::now() {
188                return Err(AuthError::Api {
189                    status: 400,
190                    code: None,
191                    message: "Session expired".into(),
192                });
193            }
194            EndpointResponse::json(&session)
195        }
196    }
197}