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