alibi_plugins/oauth/handlers/
mod.rs1mod 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 Self::Generic(message) => (message.replace(' ', "_"), None),
80 Self::AccountLookup(error) | Self::SessionAuth(error) => {
81 (error.to_string().replace(' ', "_"), None)
82 }
83 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 request_sign_up: Option<bool>,
123 additional_data: serde_json::Map<String, serde_json::Value>,
124 link: Option<OAuthStateLink>,
125 disable_redirect: bool,
126}
127
128#[derive(Clone, Default)]
130pub(crate) struct OAuthProcessPolicy {
131 pub(crate) override_user_info: bool,
132 pub(crate) require_email_verification: bool,
133 pub(crate) callback_url: Option<String>,
134 pub(crate) use_updated_user: bool,
135}
136
137impl OAuthProcessPolicy {
138 const fn for_provider(provider: &OAuthProvider, callback_url: Option<String>) -> Self {
139 Self {
140 override_user_info: provider.override_user_info_on_sign_in
141 && match &provider.authorization {
142 Some(policy) => policy.honor_factory_options,
143 None => true,
144 },
145 require_email_verification: provider.require_email_verification
146 && match &provider.authorization {
147 Some(policy) => policy.honor_factory_options,
148 None => true,
149 },
150 callback_url,
151 use_updated_user: true,
152 }
153 }
154}
155
156async fn require_session<S: alibi_core::AuthSchema>(
158 req: &AuthRequest,
159 ctx: &AuthContext<S>,
160) -> Result<alibi_core::SessionView, AuthError> {
161 ctx.require_cached_session(req)
162 .await
163 .map(|(_, session)| session)
164}
165
166pub async fn resolve_oauth_account_key(
167 provider: &OAuthProvider,
168 tokens: &OAuthTokenSet,
169 response: &mut OAuthUserInfoResponse,
170) -> AuthResult<()> {
171 if let Some(resolver) = provider
172 .authorization
173 .as_ref()
174 .and_then(|policy| policy.account_key.as_ref())
175 {
176 let subject = resolver
177 .0
178 .resolve(super::providers::OAuthAccountKeyContext {
179 tokens: tokens.clone(),
180 profile: response.data.clone(),
181 })
182 .await
183 .map_err(AuthError::internal)?;
184 response.user.id = super::providers::remaining_profile::raw_subject(Some(&subject))
185 .map_err(AuthError::internal)?;
186 } else if let Some(subject) = provider.account_subject {
187 response.user.id = subject(&response.data).map_err(AuthError::internal)?;
188 }
189 if response
190 .user
191 .id
192 .trim_matches(super::providers::remaining_profile::js_whitespace)
193 .is_empty()
194 || matches!(response.user.id.as_str(), "null" | "undefined")
195 {
196 return Err(AuthError::internal("Invalid provider subject"));
197 }
198 Ok(())
199}
200
201pub fn oauth_callback_path(provider_id: &str, provider: &OAuthProvider) -> String {
202 match provider
203 .authorization
204 .as_ref()
205 .and_then(|policy| policy.callback_path.as_deref())
206 .filter(|path| !path.is_empty())
207 {
208 Some(path) if path.starts_with('/') => path.to_owned(),
209 Some(path) => format!("/{path}"),
210 None => format!("/callback/{provider_id}"),
211 }
212}
213pub fn oauth_disable_sign_up_option(provider: &OAuthProvider) -> Option<bool> {
214 provider
215 .authorization
216 .as_ref()
217 .and_then(|policy| policy.disable_sign_up_option)
218 .or_else(|| provider.disable_sign_up.then_some(true))
219}
220
221pub(crate) fn parse_callback_user_payload(
222 user_data: Option<&str>,
223) -> Option<OAuthCallbackUserPayload> {
224 let value: serde_json::Value = serde_json::from_str(user_data?).ok()?;
225 Some(OAuthCallbackUserPayload {
226 name: value
227 .get("name")
228 .and_then(|value| value.as_object())
229 .map(|name| OAuthCallbackUserName {
230 first_name: name
231 .get("firstName")
232 .and_then(|value_2| value_2.as_str())
233 .map(String::from),
234 last_name: name
235 .get("lastName")
236 .and_then(|value_3| value_3.as_str())
237 .map(String::from),
238 }),
239 email: value
240 .get("email")
241 .and_then(|value| value.as_str())
242 .map(String::from),
243 })
244}
245
246fn raw_truthy(value: &serde_json::Value) -> bool {
247 match value {
248 serde_json::Value::Null | serde_json::Value::Bool(false) => false,
249 serde_json::Value::Number(number) => number.as_f64() != Some(0.0),
250 serde_json::Value::String(value) => !value.is_empty(),
251 serde_json::Value::Bool(true)
252 | serde_json::Value::Array(_)
253 | serde_json::Value::Object(_) => true,
254 }
255}
256
257pub(crate) struct OAuthIdentity<'a> {
259 pub provider_name: &'a str,
260 pub user: &'a OAuthUserInfo,
261 pub profile: &'a serde_json::Value,
262}
263
264enum LinkSocialOutcome {
265 Linked,
266 InvalidRawEmail,
267}
268
269#[cfg(test)]
271mod tests {
272 use crate::oauth::handlers::redirects::build_redirect_url;
273 use crate::oauth::handlers::redirects::validate_redirect_target;
274 use crate::test_helpers;
275
276 #[tokio::test]
278 async fn validate_redirect_target_respects_disable_origin_check() {
279 let config = test_helpers::create_test_config().disable_origin_check(true);
280 let ctx = test_helpers::create_test_context_with_config(config).await;
281
282 assert!(
283 validate_redirect_target("https://evil.com/phish", &ctx, "Invalid callbackURL").is_ok()
284 );
285 }
286
287 #[tokio::test]
289 async fn validate_redirect_target_rejects_untrusted_by_default() {
290 let ctx = test_helpers::create_test_context().await;
291
292 assert!(
293 validate_redirect_target("https://evil.com/phish", &ctx, "Invalid callbackURL")
294 .is_err()
295 );
296 }
297
298 #[tokio::test]
300 async fn validate_redirect_target_allows_relative() {
301 let ctx = test_helpers::create_test_context().await;
302
303 assert!(validate_redirect_target("/dashboard", &ctx, "Invalid callbackURL").is_ok());
304 }
305
306 #[test]
307 fn build_redirect_url_preserves_plus_in_path_and_encodes_spaces_in_query() {
308 let url = build_redirect_url(
309 "http://localhost:3000/api/auth",
310 Some("/dashboard+beta"),
311 &[("error_description", "space value")],
312 )
313 .expect("redirect URL should build");
314
315 assert_eq!(
316 url,
317 "http://localhost:3000/dashboard+beta?error_description=space%20value"
318 );
319 }
320}
321