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