Skip to main content

alibi_api/plugins/oauth/handlers/
mod.rs

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