alibi_plugins/one_time_token/
endpoint.rs1use 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 #[must_use]
56 pub const fn generate_endpoint() -> ServerEndpoint<OneTimeTokenOutput> {
57 ServerEndpoint::new("one-time-token", "generateOneTimeToken")
58 }
59
60 #[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 {
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}