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