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(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 Self::Generic(message) => (message.replace(' ', "_"), None),
86 Self::AccountLookup(error) | Self::SessionAuth(error) => {
87 (error.to_string().replace(' ', "_"), None)
88 }
89 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#[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
163async 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
264pub(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#[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 #[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 #[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 #[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