Skip to main content

rustauth_plugins/anonymous/
hooks.rs

1use http::header;
2use rustauth_core::api::{ApiRequest, ApiResponse};
3use rustauth_core::context::request_state::current_new_session;
4use rustauth_core::context::AuthContext;
5use rustauth_core::error::RustAuthError;
6use rustauth_core::plugin::{AuthPlugin, PluginAfterHookAction};
7use serde::Serialize;
8
9use super::model::{self, AnonymousSession, LinkedSession};
10use super::options::AnonymousOptions;
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
13pub struct AnonymousLinkAccount {
14    pub anonymous_user: AnonymousSession,
15    pub new_user: LinkedSession,
16}
17
18pub fn attach_link_hooks(mut plugin: AuthPlugin, options: AnonymousOptions) -> AuthPlugin {
19    for path in [
20        "/sign-in*",
21        "/sign-up*",
22        "/callback*",
23        "/oauth2/callback*",
24        "/magic-link/verify*",
25        "/email-otp/verify-email*",
26        "/one-tap/callback*",
27        "/passkey/verify-authentication*",
28        "/phone-number/verify*",
29    ] {
30        let options = options.clone();
31        plugin = plugin.with_async_after_hook(path, move |context, request, response| {
32            let options = options.clone();
33            Box::pin(async move { link_after_hook(context, request, response, options).await })
34        });
35    }
36    plugin
37}
38
39async fn link_after_hook(
40    context: &AuthContext,
41    request: &ApiRequest,
42    response: ApiResponse,
43    options: AnonymousOptions,
44) -> Result<PluginAfterHookAction, RustAuthError> {
45    let adapter = context.require_adapter()?;
46    let cookie_header = request
47        .headers()
48        .get(header::COOKIE)
49        .and_then(|value| value.to_str().ok())
50        .unwrap_or_default()
51        .to_owned();
52    let Some(anonymous_user) = model::current_anonymous_session(
53        adapter.as_ref(),
54        context,
55        options.storage_field_name(),
56        cookie_header,
57    )
58    .await?
59    else {
60        return Ok(PluginAfterHookAction::Continue(response));
61    };
62    if !anonymous_user.user.is_anonymous {
63        return Ok(PluginAfterHookAction::Continue(response));
64    }
65
66    let new_user = if let Some(new_user) =
67        linked_session_from_request_state(adapter.as_ref(), &options).await?
68    {
69        new_user
70    } else {
71        let Some(new_session_token) = new_session_token(context, &response)? else {
72            return Ok(PluginAfterHookAction::Continue(response));
73        };
74        let Some(new_user) = model::linked_session_from_token(
75            context,
76            adapter.as_ref(),
77            options.storage_field_name(),
78            &new_session_token,
79        )
80        .await?
81        else {
82            return Ok(PluginAfterHookAction::Continue(response));
83        };
84        new_user
85    };
86
87    finish_link(context, response, options, anonymous_user, new_user).await
88}
89
90async fn linked_session_from_request_state(
91    adapter: &dyn rustauth_core::db::DbAdapter,
92    options: &AnonymousOptions,
93) -> Result<Option<LinkedSession>, RustAuthError> {
94    let Some(new_session) = current_new_session_or_none()? else {
95        return Ok(None);
96    };
97    let Some(user) =
98        model::find_anonymous_user(adapter, options.storage_field_name(), &new_session.user.id)
99            .await?
100    else {
101        return Ok(None);
102    };
103    Ok(Some(LinkedSession {
104        session: new_session.session,
105        user,
106    }))
107}
108
109fn current_new_session_or_none(
110) -> Result<Option<rustauth_core::context::request_state::NewSession>, RustAuthError> {
111    match current_new_session() {
112        Ok(session) => Ok(session),
113        Err(RustAuthError::RequestStateMissing) => Ok(None),
114        Err(error) => Err(error),
115    }
116}
117
118async fn finish_link(
119    context: &AuthContext,
120    response: ApiResponse,
121    options: AnonymousOptions,
122    anonymous_user: AnonymousSession,
123    new_user: LinkedSession,
124) -> Result<PluginAfterHookAction, RustAuthError> {
125    if let Some(callback) = &options.on_link_account {
126        callback(AnonymousLinkAccount {
127            anonymous_user: anonymous_user.clone(),
128            new_user: new_user.clone(),
129        })
130        .await?;
131    }
132
133    if options.disable_delete_anonymous_user
134        || new_user.user.id == anonymous_user.user.id
135        || new_user.user.is_anonymous
136    {
137        return Ok(PluginAfterHookAction::Continue(response));
138    }
139
140    model::delete_anonymous_user_records(context, &anonymous_user.user.id).await?;
141
142    Ok(PluginAfterHookAction::Continue(response))
143}
144
145fn new_session_token(
146    context: &AuthContext,
147    response: &ApiResponse,
148) -> Result<Option<String>, RustAuthError> {
149    for value in response.headers().get_all(header::SET_COOKIE) {
150        let Ok(cookie) = value.to_str() else {
151            continue;
152        };
153        let Some(raw_value) = cookie_value(cookie, &context.auth_cookies.session_token.name) else {
154            continue;
155        };
156        if let Some(token) = model::verified_cookie_value(context, raw_value)? {
157            return Ok(Some(token));
158        }
159    }
160    Ok(None)
161}
162
163fn cookie_value<'a>(set_cookie: &'a str, name: &str) -> Option<&'a str> {
164    let (cookie_name, rest) = set_cookie.split_once('=')?;
165    if cookie_name.trim() != name {
166        return None;
167    }
168    Some(rest.split_once(';').map_or(rest, |(value, _)| value))
169}