alibi_plugins/oauth/handlers/
mod.rs1mod 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 Self::Generic(message) => (message.replace(' ', "_"), None),
88 Self::AccountLookup(error) | Self::SessionAuth(error) => {
89 (error.to_string().replace(' ', "_"), None)
90 }
91 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#[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
167async 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
212pub fn oauth_callback_path(provider_id: &str, provider: &OAuthProvider) -> String {
213 match provider
214 .authorization
215 .as_ref()
216 .and_then(|policy| policy.callback_path.as_deref())
217 .filter(|path| !path.is_empty())
218 {
219 Some(path) if path.starts_with('/') => path.to_owned(),
220 Some(path) => format!("/{path}"),
221 None => format!("/callback/{provider_id}"),
222 }
223}
224pub fn oauth_disable_sign_up_option(provider: &OAuthProvider) -> Option<bool> {
225 provider
226 .authorization
227 .as_ref()
228 .and_then(|policy| policy.disable_sign_up_option)
229 .or_else(|| provider.disable_sign_up.then_some(true))
230}
231
232pub(crate) fn parse_callback_user_payload(
233 user_data: Option<&str>,
234) -> Option<OAuthCallbackUserPayload> {
235 let value: serde_json::Value = serde_json::from_str(user_data?).ok()?;
236 Some(OAuthCallbackUserPayload {
237 name: value
238 .get("name")
239 .and_then(|value| value.as_object())
240 .map(|name| OAuthCallbackUserName {
241 first_name: name
242 .get("firstName")
243 .and_then(|value_2| value_2.as_str())
244 .map(String::from),
245 last_name: name
246 .get("lastName")
247 .and_then(|value_3| value_3.as_str())
248 .map(String::from),
249 }),
250 email: value
251 .get("email")
252 .and_then(|value| value.as_str())
253 .map(String::from),
254 })
255}
256
257fn raw_truthy(value: &serde_json::Value) -> bool {
258 match value {
259 serde_json::Value::Null | serde_json::Value::Bool(false) => false,
260 serde_json::Value::Number(number) => number.as_f64() != Some(0.0),
261 serde_json::Value::String(value) => !value.is_empty(),
262 serde_json::Value::Bool(true)
263 | serde_json::Value::Array(_)
264 | serde_json::Value::Object(_) => true,
265 }
266}
267
268pub(crate) struct OAuthIdentity<'a> {
270 pub provider_name: &'a str,
271 pub user: &'a OAuthUserInfo,
272 pub profile: &'a serde_json::Value,
273}
274
275enum LinkSocialOutcome {
276 Linked,
277 InvalidRawEmail,
278}
279
280#[cfg(test)]
282mod tests {
283 use crate::oauth::handlers::redirects::build_redirect_url;
284 use crate::oauth::handlers::redirects::validate_redirect_target;
285 use crate::test_helpers;
286
287 #[tokio::test]
289 async fn validate_redirect_target_respects_disable_origin_check() {
290 let config = test_helpers::create_test_config().disable_origin_check(true);
291 let ctx = test_helpers::create_test_context_with_config(config).await;
292
293 assert!(
294 validate_redirect_target("https://evil.com/phish", &ctx, "Invalid callbackURL").is_ok()
295 );
296 }
297
298 #[tokio::test]
300 async fn validate_redirect_target_rejects_untrusted_by_default() {
301 let ctx = test_helpers::create_test_context().await;
302
303 assert!(
304 validate_redirect_target("https://evil.com/phish", &ctx, "Invalid callbackURL")
305 .is_err()
306 );
307 }
308
309 #[tokio::test]
311 async fn validate_redirect_target_allows_relative() {
312 let ctx = test_helpers::create_test_context().await;
313
314 assert!(validate_redirect_target("/dashboard", &ctx, "Invalid callbackURL").is_ok());
315 }
316
317 #[test]
318 fn build_redirect_url_preserves_plus_in_path_and_encodes_spaces_in_query() {
319 let url = build_redirect_url(
320 "http://localhost:3000/api/auth",
321 Some("/dashboard+beta"),
322 &[("error_description", "space value")],
323 )
324 .expect("redirect URL should build");
325
326 assert_eq!(
327 url,
328 "http://localhost:3000/dashboard+beta?error_description=space%20value"
329 );
330 }
331}
332