Skip to main content

better_auth_api/plugins/
one_time_token.rs

1//! Single-use exchange of an existing session.
2
3use std::{future::Future, pin::Pin, sync::Arc};
4
5use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
6use better_auth_core::{
7    AuthContext, AuthError, AuthRequest, AuthResponse, AuthResult, AuthSchema, AuthSession,
8    CreateVerification,
9    utils::cookie_utils::verify_cookie_value,
10    wire::{SessionView, UserView},
11};
12use chrono::{Duration, Utc};
13use rand::RngCore;
14use sha2::{Digest, Sha256};
15
16/// Asynchronous transformation for a token stored in the verification table.
17pub type TokenHasher =
18    Arc<dyn Fn(String) -> Pin<Box<dyn Future<Output = AuthResult<String>> + Send>> + Send + Sync>;
19
20/// Storage representation shared by magic-link and one-time-token verifiers.
21#[derive(Clone, Default)]
22pub enum TokenStorage {
23    /// Store the original token, matching the upstream default.
24    #[default]
25    Plain,
26    /// Store the unpadded base64url SHA-256 digest.
27    Hashed,
28    /// Apply an application-provided asynchronous hash.
29    Custom(TokenHasher),
30}
31
32impl TokenStorage {
33    pub(crate) async fn encode(&self, token: &str) -> AuthResult<String> {
34        match self {
35            Self::Plain => Ok(token.to_owned()),
36            Self::Hashed => Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(token.as_bytes()))),
37            Self::Custom(hash) => hash(token.to_owned()).await,
38        }
39    }
40}
41
42/// Custom generation receives the authenticated session and user.
43pub type OneTimeTokenGenerator = Arc<
44    dyn Fn(SessionView, UserView) -> Pin<Box<dyn Future<Output = AuthResult<String>> + Send>>
45        + Send
46        + Sync,
47>;
48
49/// Configuration for single-use session transfer.
50#[derive(better_auth_core::PluginConfig)]
51#[plugin(name = "OneTimeTokenPlugin")]
52pub struct OneTimeTokenConfig {
53    /// Token lifetime. Upstream `expiresIn` is expressed in minutes.
54    #[config(default = Duration::minutes(3))]
55    pub expires_in: Duration,
56    /// Deny generation through the HTTP endpoint.
57    #[config(default = false)]
58    pub disable_client_request: bool,
59    /// Return the session without setting its cookie.
60    #[config(default = false)]
61    pub disable_set_session_cookie: bool,
62    /// Emit a `set-ott` header whenever a response sets a session.
63    #[config(default = false)]
64    pub set_ott_header_on_new_session: bool,
65    /// Representation used for token lookup.
66    #[config(default = TokenStorage::Plain)]
67    pub store_token: TokenStorage,
68    /// Optional application token generator.
69    #[config(default = None)]
70    pub generate_token: Option<OneTimeTokenGenerator>,
71}
72
73/// Exchange a short-lived token for an existing authenticated session.
74pub struct OneTimeTokenPlugin {
75    config: OneTimeTokenConfig,
76}
77
78better_auth_core::impl_auth_plugin! {
79    OneTimeTokenPlugin, "one-time-token";
80    routes {
81        get "/one-time-token/generate" => handle_generate, "generateOneTimeToken";
82        post "/one-time-token/verify" => handle_verify, "verifyOneTimeToken", body = verify_body;
83    }
84    extra {
85        async fn after_request(
86            &self,
87            _req: &AuthRequest,
88            response: &mut AuthResponse,
89            ctx: &AuthContext<S>,
90        ) -> AuthResult<()> {
91better_auth_core::observability::instrumentation::with_endpoint_hook(
92        &ctx.config, _req, "after", "plugin:one-time-token", async {
93            if !self.config.set_ott_header_on_new_session {
94                return Ok(());
95            }
96            let Some(token) = response_session_token(response, &ctx.config) else {
97                return Ok(());
98            };
99            let Some((session, user)) = find_session(ctx, &token).await? else {
100                return Ok(());
101            };
102            let token = self.generate(ctx, session, user).await?;
103            let _ = response.headers.insert("set-ott", token);
104            let mut exposed: Vec<_> = response.headers.get("access-control-expose-headers")
105                .map(|value| value.split(',').map(str::trim).filter(|value| !value.is_empty()).map(str::to_owned).collect())
106                .unwrap_or_default();
107            if !exposed.iter().any(|header| header == "set-ott") {
108                exposed.push("set-ott".into());
109            }
110            let _ = response.headers.insert("Access-Control-Expose-Headers", exposed.join(", "));
111            Ok(())
112         }
113    ).await
114}
115    }
116}
117
118impl OneTimeTokenPlugin {
119    /// Generate a token for a session supplied by trusted server code.
120    pub async fn generate<S: AuthSchema>(
121        &self,
122        ctx: &AuthContext<S>,
123        session: SessionView,
124        user: UserView,
125    ) -> AuthResult<String> {
126        let session_token = session.token.clone();
127        let token = match &self.config.generate_token {
128            Some(generate) => generate(session, user).await?,
129            None => {
130                let mut bytes = [0_u8; 24];
131                rand::thread_rng().fill_bytes(&mut bytes);
132                URL_SAFE_NO_PAD.encode(bytes)
133            }
134        };
135        let expires_at = Utc::now() + self.config.expires_in;
136        let stored = self.config.store_token.encode(&token).await?;
137        let _ = ctx
138            .database
139            .create_verification(CreateVerification {
140                identifier: (format!("one-time-token:{stored}")).into(),
141                value: (session_token).into(),
142                expires_at: expires_at.into(),
143                ..Default::default()
144            })
145            .await?;
146        Ok(token)
147    }
148
149    async fn handle_generate<S: AuthSchema>(
150        &self,
151        req: &AuthRequest,
152        ctx: &AuthContext<S>,
153    ) -> AuthResult<AuthResponse> {
154        let (user, session) = ctx.require_session(req).await.map_err(session_required)?;
155        if self.config.disable_client_request {
156            return message_error("Client requests are disabled");
157        }
158        let token = self.generate(ctx, session, user).await?;
159        Ok(AuthResponse::json(
160            200,
161            &serde_json::json!({ "token": token }),
162        )?)
163    }
164
165    async fn handle_verify<S: AuthSchema>(
166        &self,
167        req: &AuthRequest,
168        ctx: &AuthContext<S>,
169    ) -> AuthResult<AuthResponse> {
170        let token = match token_body(req, "token") {
171            Ok(token) => token,
172            Err(response) => return Ok(response),
173        };
174        let stored = self.config.store_token.encode(&token).await?;
175        let Some(verification) = ctx
176            .database
177            .consume_verification_by_identifier(&format!("one-time-token:{stored}"))
178            .await?
179        else {
180            return message_error("Invalid token");
181        };
182        let Some((session, user)) = find_session(ctx, verification.value.typed()?).await? else {
183            return message_error("Session not found");
184        };
185        let response = if session.expires_at.milliseconds() < Utc::now().timestamp_millis() as f64 {
186            message_error("Session expired")?
187        } else {
188            AuthResponse::json(
189                200,
190                &serde_json::json!({ "session": session, "user": user }),
191            )?
192        };
193        if !self.config.disable_set_session_cookie {
194            ctx.session_manager()
195                .set_session_cookie(
196                    req,
197                    better_auth_core::session::SessionData { session, user },
198                    None,
199                )
200                .await?;
201        }
202        Ok(response)
203    }
204}
205
206fn message_error(message: &str) -> AuthResult<AuthResponse> {
207    Ok(AuthResponse::json(
208        400,
209        &serde_json::json!({ "message": message }),
210    )?)
211}
212
213pub(crate) fn session_required(error: AuthError) -> AuthError {
214    match error {
215        AuthError::Unauthenticated => AuthError::Upstream {
216            status: 401,
217            code: "UNAUTHORIZED",
218            message: "Unauthorized",
219        },
220        error => error,
221    }
222}
223
224#[derive(serde::Deserialize)]
225struct TokenBody {
226    #[serde(alias = "sessionToken")]
227    token: String,
228}
229fn token_input(req: &AuthRequest, field: &str) -> AuthResult<(String, serde_json::Value)> {
230    let (body, projection) = super::json_body::string_input::<TokenBody>(req, &[(field, true)])?;
231    Ok((body.token, projection))
232}
233fn verify_body(req: &AuthRequest) -> AuthResult<better_auth_core::endpoint_input::ValidatedBody> {
234    let (body, projection) = token_input(req, "token")?;
235    Ok(better_auth_core::endpoint_input::ValidatedBody::new(
236        Some(projection),
237        body,
238    ))
239}
240pub(crate) fn session_token_body(
241    req: &AuthRequest,
242) -> AuthResult<better_auth_core::endpoint_input::ValidatedBody> {
243    let (body, projection) = token_input(req, "sessionToken")?;
244    Ok(better_auth_core::endpoint_input::ValidatedBody::new(
245        Some(projection),
246        body,
247    ))
248}
249pub(crate) fn token_body(req: &AuthRequest, field: &str) -> Result<String, AuthResponse> {
250    match req.validated_body::<String>() {
251        Some(body) => Ok(body.clone()),
252        None => token_input(req, field)
253            .map(|(body, _)| body)
254            .map_err(|error| error.to_auth_response()),
255    }
256}
257
258pub(crate) async fn find_session<S: AuthSchema>(
259    ctx: &AuthContext<S>,
260    token: &str,
261) -> AuthResult<Option<(SessionView, UserView)>> {
262    let Some((session, snapshot)) = ctx.database.get_session_snapshot(token).await? else {
263        return Ok(None);
264    };
265    if let Some(data) = snapshot {
266        let Some(mut data) = data.into_typed()? else {
267            return Ok(None);
268        };
269        data.session.filter_returned_fields(&ctx.config.session);
270        return Ok(Some((data.session, ctx.user_view(&data.user).await?)));
271    }
272    let Some(user) = ctx
273        .database
274        .get_user_by_id(session.user_id().typed()?)
275        .await?
276    else {
277        return Ok(None);
278    };
279    Ok(Some((
280        ctx.session_view(&session).await?,
281        ctx.user_view(&user).await?,
282    )))
283}
284
285pub(crate) fn response_session_token(
286    response: &AuthResponse,
287    config: &better_auth_core::AuthConfig,
288) -> Option<String> {
289    response
290        .headers
291        .get_all("set-cookie")
292        .filter_map(|value| cookie::Cookie::parse(value.as_str()).ok())
293        .filter(|cookie| {
294            cookie.name() == config.auth_cookie("session_token", Default::default()).name
295        })
296        .last()
297        .filter(|cookie| {
298            !cookie.value().is_empty()
299                && cookie.max_age().is_none_or(|age| age.whole_seconds() != 0)
300        })
301        .and_then(|cookie| verify_cookie_value(cookie.value(), config.signing_secret()))
302}
303
304#[cfg(test)]
305mod tests;