alibi_core/
user_validation.rs1use crate::config::AuthConfig;
3use crate::error::{AuthError, AuthResult};
4use crate::hooks::{RequestHookContext, current_request_hook_context};
5use crate::types::CreateUser;
6use async_trait::async_trait;
7use chrono::Utc;
8
9#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
12#[serde(rename_all = "kebab-case")]
13pub enum UserValidationAction {
14 #[default]
15 CreateUser,
16 LinkAccount,
17 SignIn,
18}
19
20#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
22#[serde(rename_all = "camelCase")]
23pub struct UserValidationProvider {
24 pub provider_id: String,
25 #[serde(skip_serializing_if = "Option::is_none")]
28 pub profile: Option<serde_json::Value>,
29}
30
31#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
34#[serde(rename_all = "camelCase")]
35pub struct UserValidationSource {
36 #[serde(default)]
37 pub method: String,
38 #[serde(default)]
39 pub action: UserValidationAction,
40 #[serde(skip_serializing_if = "Option::is_none")]
41 pub oauth: Option<UserValidationProvider>,
42 #[serde(skip_serializing_if = "Option::is_none")]
43 pub sso: Option<UserValidationProvider>,
44}
45
46impl UserValidationSource {
47 #[must_use]
48 pub fn creation(method: impl Into<String>) -> Self {
49 Self {
50 method: method.into(),
51 action: UserValidationAction::CreateUser,
52 oauth: None,
53 sso: None,
54 }
55 }
56
57 #[must_use]
58 pub fn oauth(
59 provider_id: impl Into<String>,
60 profile: &serde_json::Value,
61 action: UserValidationAction,
62 ) -> Self {
63 Self {
64 method: "oauth".into(),
65 action,
66 oauth: Some(UserValidationProvider {
67 provider_id: provider_id.into(),
68 profile: profile.is_object().then(|| profile.clone()),
69 }),
70 sso: None,
71 }
72 }
73}
74
75#[derive(Debug, Clone)]
78pub struct UserValidationData {
79 pub user: CreateUser,
80 pub source: UserValidationSource,
81}
82
83#[derive(Debug, Clone)]
86pub struct UserValidationRejection {
87 pub error: String,
88 pub error_description: Option<String>,
89}
90
91#[async_trait]
92pub trait UserInfoValidator: Send + Sync {
93 async fn validate(
97 &self,
98 data: &mut UserValidationData,
99 request: &RequestHookContext,
100 ) -> AuthResult<Option<UserValidationRejection>>;
101}
102
103fn denial(code: &str, message: &str) -> AuthError {
104 AuthError::Api {
105 status: 403,
106 code: Some(code.into()),
107 message: message.into(),
108 }
109}
110
111fn assert_source(source: Option<&UserValidationSource>) -> AuthResult<()> {
112 let Some(source) = source.filter(|source| !source.method.is_empty()) else {
113 return Err(denial(
114 "validation_source_missing",
115 "User validation source is required",
116 ));
117 };
118 if source.method == "oauth"
119 && source
120 .oauth
121 .as_ref()
122 .is_none_or(|provider| provider.provider_id.is_empty())
123 {
124 return Err(denial(
125 "validation_source_missing",
126 "OAuth user validation source requires oauth.providerId",
127 ));
128 }
129 if matches!(source.method.as_str(), "sso-oidc" | "sso-saml")
130 && source
131 .sso
132 .as_ref()
133 .is_none_or(|provider| provider.provider_id.is_empty())
134 {
135 return Err(denial(
136 "validation_source_missing",
137 "SSO user validation source requires sso.providerId",
138 ));
139 }
140 Ok(())
141}
142
143pub async fn validate_user_info(
146 config: &AuthConfig,
147 data: &mut UserValidationData,
148) -> AuthResult<()> {
149 let Some(validator) = &config.user_validation else {
150 return Ok(());
151 };
152 assert_source(Some(&data.source))?;
153 let request = current_request_hook_context().ok_or_else(|| {
154 denial(
155 "validation_context_missing",
156 "User validation requires an endpoint context",
157 )
158 })?;
159 match validator.validate(data, &request).await {
160 Ok(Some(rejection)) if !rejection.error.is_empty() => {
161 let message = rejection
162 .error_description
163 .as_deref()
164 .filter(|value| !value.is_empty())
165 .unwrap_or(&rejection.error);
166 Err(denial(&rejection.error, message))
167 }
168 Ok(_) => Ok(()),
169 Err(_) => Err(denial("validation_failed", "User validation failed")),
170 }
171}
172
173pub struct PreparedUserCreation {
177 data: CreateUser,
178 defaults: crate::store::UserCreationDefaults,
179}
180
181impl PreparedUserCreation {
182 #[must_use]
183 pub fn into_data(self) -> CreateUser {
184 self.data
185 }
186
187 #[must_use]
190 pub fn into_parts(self) -> (CreateUser, crate::store::UserCreationDefaults) {
191 (self.data, self.defaults)
192 }
193
194 pub(crate) fn from_data(data: CreateUser) -> Self {
195 Self {
196 data,
197 defaults: crate::store::UserCreationDefaults::default(),
198 }
199 }
200
201 pub(crate) fn with_defaults(mut self, defaults: crate::store::UserCreationDefaults) -> Self {
202 self.defaults = defaults;
203 self
204 }
205}
206
207pub(crate) async fn prepare_creation(
208 config: &AuthConfig,
209 mut user: CreateUser,
210 source: Option<UserValidationSource>,
211) -> AuthResult<PreparedUserCreation> {
212 assert_source(source.as_ref())?;
213 let mut source = source.ok_or_else(|| {
214 denial(
215 "validation_source_missing",
216 "User validation source is required",
217 )
218 })?;
219 source.action = UserValidationAction::CreateUser;
220 let now = Utc::now();
221 _ = user.created_at.get_or_insert(now);
222 _ = user.updated_at.get_or_insert(now);
223 user.email = user
224 .email
225 .map(|email| crate::utils::normalize_user_email(&email));
226 let mut data = UserValidationData { user, source };
227 validate_user_info(config, &mut data).await?;
228 if data.user.created_at.is_none() || data.user.updated_at.is_none() {
229 return Err(AuthError::NotImplemented(
230 "Identity policy removal of creation timestamps is not supported".into(),
231 ));
232 }
233 Ok(PreparedUserCreation::from_data(data.user))
234}