Skip to main content

alibi_plugins/oauth/handlers/
mod.rs

1mod authorization;
2mod cookies;
3mod http;
4mod identity;
5mod linking;
6mod redirects;
7mod token_exchange;
8
9use super::providers::{
10    OAuthCallbackUserName, OAuthCallbackUserPayload, OAuthProvider, OAuthTokenSet, OAuthUserInfo,
11    OAuthUserInfoResponse,
12};
13use super::state::{AccountCookiePayload, OAuthStateLink, OAuthStatePayload};
14use super::types::SocialSignInResponse;
15use crate::helpers::SessionIssueError;
16use alibi_core::error::codes;
17use alibi_core::wire::{SessionView, UserView};
18use alibi_core::{AuthContext, AuthError, AuthRequest, AuthResult};
19pub(super) use authorization::{
20    link_social_core, social_sign_in_core, validate_authorization_params,
21};
22pub(crate) use cookies::create_account_cookie_headers;
23pub(super) use cookies::decode_account_cookie;
24pub(super) use cookies::{cookie_state_header, state_cookie_header};
25pub(super) use http::handle_link_social;
26pub(crate) use http::handle_social_sign_in;
27pub(super) use http::{callback_core, callback_post_response, handle_callback};
28pub(crate) use identity::process_oauth_sign_in;
29pub(crate) use identity::process_oauth_sign_in_with_output;
30pub(crate) use linking::complete_link_social;
31pub(super) use linking::{link_with_id_token_core, sign_in_with_id_token_core};
32pub(crate) use redirects::ambiguous_account_sign_in_response;
33pub(super) use redirects::callback_failure_redirect;
34pub(crate) use token_exchange::fetch_user_info_from_provider;
35pub(super) use token_exchange::refresh_tokens_via_provider;
36pub(crate) use token_exchange::validate_authorization_code_via_provider;
37
38pub(crate) struct ProcessOAuthUserResult {
39    pub(crate) session: SessionView,
40    pub(crate) user: UserView,
41    pub(crate) is_register: bool,
42    pub(crate) account_cookie: Option<AccountCookiePayload>,
43}
44
45pub(crate) enum OAuthSignInError {
46    Generic(String),
47    AccountLookup(AuthError),
48    SessionAuth(AuthError),
49    IdentityDenied { code: String, message: String },
50    Banned(String),
51    EmailNotVerified,
52}
53
54impl OAuthSignInError {
55    fn from_identity_denial(error: &AuthError) -> Self {
56        let (_, code, message) = error.error_payload();
57        Self::IdentityDenied {
58            code: code.unwrap_or_else(|| "validation_failed".into()),
59            message,
60        }
61    }
62    fn from_account_lookup(error: AuthError) -> Self {
63        if matches!(
64            error,
65            AuthError::Database(alibi_core::DatabaseError::AmbiguousAccount { .. })
66        ) {
67            Self::AccountLookup(error)
68        } else {
69            Self::Generic(error.to_string())
70        }
71    }
72
73    pub(crate) fn is_ambiguous_account(&self) -> bool {
74        matches!(
75            self,
76            Self::AccountLookup(AuthError::Database(
77                alibi_core::DatabaseError::AmbiguousAccount { .. }
78            ))
79        )
80    }
81
82    pub(crate) fn redirect_parts(&self) -> (String, Option<&str>) {
83        match self {
84            // Upstream turns a plain internal error string into the `error`
85            // param verbatim, with no description.
86            Self::Generic(message) => (message.replace(' ', "_"), None),
87            Self::AccountLookup(error) | Self::SessionAuth(error) => {
88                (error.to_string().replace(' ', "_"), None)
89            }
90            // An APIError instead redirects with its `code` and message, so the
91            // param is the constant, not a lowercased word.
92            Self::Banned(message) => (codes::BANNED_USER.to_owned(), Some(message.as_str())),
93            Self::IdentityDenied { code, message } => (code.clone(), Some(message.as_str())),
94            Self::EmailNotVerified => ("email_not_verified".to_owned(), None),
95        }
96    }
97}
98
99impl From<String> for OAuthSignInError {
100    fn from(value: String) -> Self {
101        Self::Generic(value)
102    }
103}
104
105impl From<SessionIssueError> for OAuthSignInError {
106    fn from(value: SessionIssueError) -> Self {
107        match value {
108            SessionIssueError::Auth(error) => Self::SessionAuth(error),
109            SessionIssueError::Banned { message } => Self::Banned(message),
110        }
111    }
112}
113
114pub(super) struct InitiatedOAuthFlow {
115    pub(super) response: SocialSignInResponse,
116    pub(super) state: String,
117    pub(super) payload: OAuthStatePayload,
118}
119
120struct FlowStartRequest<'a> {
121    provider_name: &'a str,
122    provider: &'a OAuthProvider,
123    callback_url: &'a str,
124    new_user_callback_url: Option<String>,
125    error_callback_url: Option<String>,
126    scopes: Option<&'a [String]>,
127    login_hint: Option<&'a str>,
128    additional_params: Option<&'a std::collections::BTreeMap<String, String>>,
129    authorization_params: Option<&'a std::collections::BTreeMap<String, String>>,
130    request_sign_up: Option<bool>,
131    additional_data: serde_json::Map<String, serde_json::Value>,
132    link: Option<OAuthStateLink>,
133    disable_redirect: bool,
134}
135
136/// Normalized policy shared by social callbacks and One Tap.
137#[derive(Clone, Default)]
138pub(crate) struct OAuthProcessPolicy {
139    pub(crate) override_user_info: bool,
140    pub(crate) require_email_verification: bool,
141    pub(crate) callback_url: Option<String>,
142    pub(crate) use_updated_user: bool,
143}
144
145impl OAuthProcessPolicy {
146    const fn for_provider(provider: &OAuthProvider, callback_url: Option<String>) -> Self {
147        Self {
148            override_user_info: provider.override_user_info_on_sign_in
149                && match &provider.authorization {
150                    Some(policy) => policy.honor_factory_options,
151                    None => true,
152                },
153            require_email_verification: provider.require_email_verification
154                && match &provider.authorization {
155                    Some(policy) => policy.honor_factory_options,
156                    None => true,
157                },
158            callback_url,
159            use_updated_user: true,
160        }
161    }
162}
163
164/// Authenticate the current request and return the validated session.
165async fn require_session<S: alibi_core::AuthSchema>(
166    req: &AuthRequest,
167    ctx: &AuthContext<S>,
168) -> Result<alibi_core::SessionView, AuthError> {
169    ctx.require_cached_session(req)
170        .await
171        .map(|(_, session)| session)
172}
173
174pub async fn resolve_oauth_account_key(
175    provider: &OAuthProvider,
176    tokens: &OAuthTokenSet,
177    response: &mut OAuthUserInfoResponse,
178) -> AuthResult<()> {
179    if let Some(resolver) = provider
180        .authorization
181        .as_ref()
182        .and_then(|policy| policy.account_key.as_ref())
183    {
184        let subject = resolver
185            .0
186            .resolve(super::providers::OAuthAccountKeyContext {
187                tokens: tokens.clone(),
188                profile: response.data.clone(),
189            })
190            .await
191            .map_err(AuthError::internal)?;
192        response.user.id = super::providers::remaining_profile::raw_subject(Some(&subject))
193            .map_err(AuthError::internal)?;
194    } else if let Some(subject) = provider.account_subject {
195        response.user.id = subject(&response.data).map_err(AuthError::internal)?;
196    }
197    if response
198        .user
199        .id
200        .trim_matches(super::providers::remaining_profile::js_whitespace)
201        .is_empty()
202        || matches!(response.user.id.as_str(), "null" | "undefined")
203    {
204        return Err(AuthError::internal("Invalid provider subject"));
205    }
206    Ok(())
207}
208
209pub fn oauth_callback_path(provider_id: &str, provider: &OAuthProvider) -> String {
210    match provider
211        .authorization
212        .as_ref()
213        .and_then(|policy| policy.callback_path.as_deref())
214        .filter(|path| !path.is_empty())
215    {
216        Some(path) if path.starts_with('/') => path.to_owned(),
217        Some(path) => format!("/{path}"),
218        None => format!("/callback/{provider_id}"),
219    }
220}
221pub fn oauth_disable_sign_up_option(provider: &OAuthProvider) -> Option<bool> {
222    provider
223        .authorization
224        .as_ref()
225        .and_then(|policy| policy.disable_sign_up_option)
226        .or_else(|| provider.disable_sign_up.then_some(true))
227}
228
229pub(crate) fn parse_callback_user_payload(
230    user_data: Option<&str>,
231) -> Option<OAuthCallbackUserPayload> {
232    let value: serde_json::Value = serde_json::from_str(user_data?).ok()?;
233    Some(OAuthCallbackUserPayload {
234        name: value
235            .get("name")
236            .and_then(|value| value.as_object())
237            .map(|name| OAuthCallbackUserName {
238                first_name: name
239                    .get("firstName")
240                    .and_then(|value_2| value_2.as_str())
241                    .map(String::from),
242                last_name: name
243                    .get("lastName")
244                    .and_then(|value_3| value_3.as_str())
245                    .map(String::from),
246            }),
247        email: value
248            .get("email")
249            .and_then(|value| value.as_str())
250            .map(String::from),
251    })
252}
253
254fn raw_truthy(value: &serde_json::Value) -> bool {
255    match value {
256        serde_json::Value::Null | serde_json::Value::Bool(false) => false,
257        serde_json::Value::Number(number) => number.as_f64() != Some(0.0),
258        serde_json::Value::String(value) => !value.is_empty(),
259        serde_json::Value::Bool(true)
260        | serde_json::Value::Array(_)
261        | serde_json::Value::Object(_) => true,
262    }
263}
264
265/// Mapped provider identity together with its original provenance.
266pub(crate) struct OAuthIdentity<'a> {
267    pub provider_name: &'a str,
268    pub user: &'a OAuthUserInfo,
269    pub profile: &'a serde_json::Value,
270}
271
272enum LinkSocialOutcome {
273    Linked,
274    InvalidRawEmail,
275}
276
277// LCOV_EXCL_START
278#[cfg(test)]
279mod tests {
280    use crate::oauth::handlers::redirects::build_redirect_url;
281    use crate::oauth::handlers::redirects::validate_redirect_target;
282    use crate::test_helpers;
283
284    // Upstream reference: packages/better-auth/src/api/middlewares/origin-check.ts :: originCheck respects ctx.context.skipOriginCheck.
285    #[tokio::test]
286    async fn validate_redirect_target_respects_disable_origin_check() {
287        let config = test_helpers::create_test_config().disable_origin_check(true);
288        let ctx = test_helpers::create_test_context_with_config(config).await;
289
290        assert!(
291            validate_redirect_target("https://evil.com/phish", &ctx, "Invalid callbackURL").is_ok()
292        );
293    }
294
295    // Upstream reference: packages/better-auth/src/api/middlewares/origin-check.ts :: originCheck rejects untrusted origins by default.
296    #[tokio::test]
297    async fn validate_redirect_target_rejects_untrusted_by_default() {
298        let ctx = test_helpers::create_test_context().await;
299
300        assert!(
301            validate_redirect_target("https://evil.com/phish", &ctx, "Invalid callbackURL")
302                .is_err()
303        );
304    }
305
306    // Upstream reference: packages/better-auth/src/api/middlewares/origin-check.ts :: originCheck allows relative paths.
307    #[tokio::test]
308    async fn validate_redirect_target_allows_relative() {
309        let ctx = test_helpers::create_test_context().await;
310
311        assert!(validate_redirect_target("/dashboard", &ctx, "Invalid callbackURL").is_ok());
312    }
313
314    #[test]
315    fn build_redirect_url_preserves_plus_in_path_and_encodes_spaces_in_query() {
316        let url = build_redirect_url(
317            "http://localhost:3000/api/auth",
318            Some("/dashboard+beta"),
319            &[("error_description", "space value")],
320        )
321        .expect("redirect URL should build");
322
323        assert_eq!(
324            url,
325            "http://localhost:3000/dashboard+beta?error_description=space%20value"
326        );
327    }
328}
329// LCOV_EXCL_STOP