Skip to main content

openauth_plugins/anonymous/
hooks.rs

1use http::header;
2use openauth_core::api::{ApiRequest, ApiResponse};
3use openauth_core::context::request_state::current_new_session;
4use openauth_core::context::AuthContext;
5use openauth_core::error::OpenAuthError;
6use openauth_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, OpenAuthError> {
45    let adapter = context.adapter().ok_or_else(|| {
46        OpenAuthError::Adapter("anonymous plugin requires a database adapter".to_owned())
47    })?;
48    let cookie_header = request
49        .headers()
50        .get(header::COOKIE)
51        .and_then(|value| value.to_str().ok())
52        .unwrap_or_default()
53        .to_owned();
54    let Some(anonymous_user) = model::current_anonymous_session(
55        adapter.as_ref(),
56        context,
57        options.storage_field_name(),
58        cookie_header,
59    )
60    .await?
61    else {
62        return Ok(PluginAfterHookAction::Continue(response));
63    };
64    if !anonymous_user.user.is_anonymous {
65        return Ok(PluginAfterHookAction::Continue(response));
66    }
67
68    let new_user = if let Some(new_user) =
69        linked_session_from_request_state(adapter.as_ref(), &options).await?
70    {
71        new_user
72    } else {
73        let Some(new_session_token) = new_session_token(context, &response)? else {
74            return Ok(PluginAfterHookAction::Continue(response));
75        };
76        let Some(new_user) = model::linked_session_from_token(
77            adapter.as_ref(),
78            options.storage_field_name(),
79            &new_session_token,
80        )
81        .await?
82        else {
83            return Ok(PluginAfterHookAction::Continue(response));
84        };
85        new_user
86    };
87
88    finish_link(
89        response,
90        adapter.as_ref(),
91        options,
92        anonymous_user,
93        new_user,
94    )
95    .await
96}
97
98async fn linked_session_from_request_state(
99    adapter: &dyn openauth_core::db::DbAdapter,
100    options: &AnonymousOptions,
101) -> Result<Option<LinkedSession>, OpenAuthError> {
102    let Some(new_session) = current_new_session_or_none()? else {
103        return Ok(None);
104    };
105    let Some(user) =
106        model::find_anonymous_user(adapter, options.storage_field_name(), &new_session.user.id)
107            .await?
108    else {
109        return Ok(None);
110    };
111    Ok(Some(LinkedSession {
112        session: new_session.session,
113        user,
114    }))
115}
116
117fn current_new_session_or_none(
118) -> Result<Option<openauth_core::context::request_state::NewSession>, OpenAuthError> {
119    match current_new_session() {
120        Ok(session) => Ok(session),
121        Err(OpenAuthError::RequestStateMissing) => Ok(None),
122        Err(error) => Err(error),
123    }
124}
125
126async fn finish_link(
127    response: ApiResponse,
128    adapter: &dyn openauth_core::db::DbAdapter,
129    options: AnonymousOptions,
130    anonymous_user: AnonymousSession,
131    new_user: LinkedSession,
132) -> Result<PluginAfterHookAction, OpenAuthError> {
133    if let Some(callback) = &options.on_link_account {
134        callback(AnonymousLinkAccount {
135            anonymous_user: anonymous_user.clone(),
136            new_user: new_user.clone(),
137        })
138        .await?;
139    }
140
141    if options.disable_delete_anonymous_user
142        || new_user.user.id == anonymous_user.user.id
143        || new_user.user.is_anonymous
144    {
145        return Ok(PluginAfterHookAction::Continue(response));
146    }
147
148    model::delete_anonymous_user_records(adapter, &anonymous_user.user.id).await?;
149
150    Ok(PluginAfterHookAction::Continue(response))
151}
152
153fn new_session_token(
154    context: &AuthContext,
155    response: &ApiResponse,
156) -> Result<Option<String>, OpenAuthError> {
157    for value in response.headers().get_all(header::SET_COOKIE) {
158        let Ok(cookie) = value.to_str() else {
159            continue;
160        };
161        let Some(raw_value) = cookie_value(cookie, &context.auth_cookies.session_token.name) else {
162            continue;
163        };
164        if let Some(token) = model::verified_cookie_value(context, raw_value)? {
165            return Ok(Some(token));
166        }
167    }
168    Ok(None)
169}
170
171fn cookie_value<'a>(set_cookie: &'a str, name: &str) -> Option<&'a str> {
172    let (cookie_name, rest) = set_cookie.split_once('=')?;
173    if cookie_name.trim() != name {
174        return None;
175    }
176    Some(rest.split_once(';').map_or(rest, |(value, _)| value))
177}