Skip to main content

alibi_plugins/one_time_token/
endpoint.rs

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