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