alibi_api/plugins/oauth/handlers/
mod.rs1mod 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 Self::Generic(message) => (message.replace(' ', "_"), None),
121 Self::AccountLookup(error) | Self::SessionAuth(error) => {
122 (error.to_string().replace(' ', "_"), None)
123 }
124 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#[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
197async 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
302pub(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#[cfg(test)]
316mod tests {
317 use super::*;
318 use crate::plugins::test_helpers;
319
320 #[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 #[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 #[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