alibi_plugins/user_management/
update.rs1use alibi_core::entity::AuthUser;
2use alibi_core::error::codes;
3use alibi_core::session::SessionRequest;
4use alibi_core::utils::json::JsValue;
5use alibi_core::utils::username::UsernameConfig;
6use alibi_core::{
7 AuthContext, AuthError, AuthRequest, AuthResponse, AuthResult, ErrorCodeMessageResponse,
8 UpdateUser, UpdateUserRequest,
9};
10
11pub fn update_user_endpoint(
16 body: &impl serde::Serialize,
17) -> AuthResult<alibi_core::endpoint::ServerEndpoint<alibi_core::StatusResponse>> {
18 alibi_core::endpoint::ServerEndpoint::new("core", "updateUser").with_body(body)
19}
20
21#[must_use]
23pub fn ok_endpoint() -> alibi_core::endpoint::ServerEndpoint<alibi_core::OkResponse> {
24 alibi_core::endpoint::ServerEndpoint::new("core", "ok")
25}
26
27#[must_use]
29pub fn error_endpoint() -> alibi_core::endpoint::ServerEndpoint<alibi_core::AuthResponse> {
30 alibi_core::endpoint::ServerEndpoint::new("core", "error")
31}
32
33#[doc(hidden)]
35pub struct BuiltinUserEndpoints;
36#[async_trait::async_trait]
37impl<S: alibi_core::AuthSchema> alibi_core::AuthPlugin<S> for BuiltinUserEndpoints {
38 fn name(&self) -> &'static str {
39 "core"
40 }
41 fn routes(&self) -> Vec<alibi_core::AuthRoute> {
42 Vec::new()
43 }
44 fn server_endpoints(&self) -> Vec<alibi_core::endpoint::EndpointDefinition> {
45 vec![
46 crate::endpoint::definition("ok", "ok", Some("/ok"), alibi_core::HttpMethod::Get),
47 crate::endpoint::definition(
48 "error",
49 "error",
50 Some("/error"),
51 alibi_core::HttpMethod::Get,
52 ),
53 crate::endpoint::definition(
54 "updateUser",
55 "updateUser",
56 Some("/update-user"),
57 alibi_core::HttpMethod::Post,
58 ),
59 ]
60 }
61 fn validate_endpoint(
62 &self,
63 call: &alibi_core::endpoint::EndpointCall,
64 _: &AuthContext<S>,
65 ) -> AuthResult<alibi_core::endpoint::EndpointInput> {
66 if matches!(call.operation_id(), "ok" | "error") {
67 return Ok(alibi_core::endpoint::EndpointInput {
68 body: call.body().cloned(),
69 query: call.query().cloned(),
70 });
71 }
72 let body = call
73 .body()
74 .filter(|body| body.as_object().is_some())
75 .ok_or_else(|| {
76 crate::endpoint::validation(format!(
77 "[body] Invalid input: expected record, received {}",
78 crate::authentication_helpers::json_type(call.body())
79 ))
80 })?;
81 Ok(alibi_core::endpoint::EndpointInput {
82 body: Some(body.clone()),
83 query: call.query().cloned(),
84 })
85 }
86 async fn on_endpoint(
87 &self,
88 call: &alibi_core::endpoint::EndpointCall,
89 context: &AuthContext<S>,
90 ) -> AuthResult<alibi_core::endpoint::EndpointResponse> {
91 if call.operation_id() == "error" {
92 let url = call
93 .request()
94 .and_then(|request| request.url())
95 .ok_or_else(|| AuthError::internal("Invalid URL"))?;
96 let parameter = |name: &str| {
97 url.query_pairs()
98 .find_map(|(key, value)| (key == name).then(|| value.into_owned()))
99 };
100 let code = parameter("error").unwrap_or_else(|| "UNKNOWN".into());
101 let description = parameter("error_description");
102 let response = alibi_core::error::page::error_page_response(
103 &context.config,
104 &code,
105 description.as_deref().filter(|value| !value.is_empty()),
106 )?;
107 return Ok(alibi_core::endpoint::EndpointResponse::from_http_response(
108 response,
109 ));
110 }
111 if call.operation_id() == "ok" {
112 return alibi_core::endpoint::EndpointResponse::json(&alibi_core::OkResponse {
113 ok: true,
114 });
115 }
116 let body = call
117 .body()
118 .ok_or_else(|| AuthError::bad_request("Missing profile input"))?;
119 let (user, session) = context
120 .require_cached_session(call)
121 .await
122 .map_err(crate::helpers::unauthorized_if_session_missing)?;
123 call.record_authenticated_session(context.user_view(&user), session.clone());
124 let response = update_user_core(call, body, user, session, context).await?;
125 if response.status >= 400 {
126 return Err(crate::endpoint::error_response(response));
127 }
128 let mut result = alibi_core::endpoint::EndpointResponse::value(
131 alibi_core::utils::json::from_slice(&response.body)?,
132 );
133 for (name, value) in response.headers {
134 if !name.eq_ignore_ascii_case("content-type") {
135 result = result.with_header(name, value);
136 }
137 }
138 Ok(result)
139 }
140 async fn on_request(
141 &self,
142 _: &AuthRequest,
143 _: &AuthContext<S>,
144 ) -> AuthResult<Option<AuthResponse>> {
145 Ok(None)
146 }
147}
148
149pub async fn handle_update_user<S: alibi_core::AuthSchema>(
154 req: &AuthRequest,
155 context: &AuthContext<S>,
156) -> AuthResult<AuthResponse> {
157 let (user, session) = context
159 .require_cached_session(req)
160 .await
161 .map_err(crate::helpers::unauthorized_if_session_missing)?;
162 _ = req
163 .body_as_json::<serde_json::Value>()
164 .map_err(|error| AuthError::bad_request(format!("Invalid JSON: {error}")))?;
165 let raw_body: JsValue = req.body_as_json()?;
166 update_user_core(req, &raw_body, user, session, context).await
167}
168
169#[expect(
170 clippy::too_many_lines,
171 reason = "Keep field validation and callbacks adjacent to their profile persistence owner"
172)]
173async fn update_user_core<S: alibi_core::AuthSchema>(
174 req: &impl SessionRequest,
175 raw_body: &JsValue,
176 current_user: alibi_core::AuthenticatedUser<S>,
177 current_session: alibi_core::SessionView,
178 context: &AuthContext<S>,
179) -> AuthResult<AuthResponse> {
180 let body: serde_json::Value = raw_body.to_json_value()?;
181 let Some(body) = body.as_object() else {
182 let actual = match &body {
183 serde_json::Value::Null => "null",
184 serde_json::Value::Bool(_) => "boolean",
185 serde_json::Value::Number(_) => "number",
186 serde_json::Value::String(_) => "string",
187 serde_json::Value::Array(_) => "array",
188 serde_json::Value::Object(_) => "record",
189 };
190 return Ok(AuthResponse::json(
191 400,
192 &ErrorCodeMessageResponse {
193 code: Some(codes::VALIDATION_ERROR.to_owned()),
194 message: format!("[body] Invalid input: expected record, received {actual}"),
195 },
196 )?);
197 };
198
199 if body.get("email").is_some_and(|value| match value {
200 serde_json::Value::Null => false,
201 serde_json::Value::Bool(value) => *value,
202 serde_json::Value::Number(value) => value.as_f64().is_some_and(|value| value != 0.0),
203 serde_json::Value::String(value) => !value.is_empty(),
204 serde_json::Value::Array(_) | serde_json::Value::Object(_) => true,
205 }) {
206 return Err(AuthError::bad_request("Email can not be updated"));
207 }
208
209 if body.contains_key("role") {
212 return Err(AuthError::Api {
213 status: 400,
214 code: Some(codes::FIELD_NOT_ALLOWED.into()),
215 message: "role is not allowed to be set".into(),
216 });
217 }
218
219 crate::last_login_method::reject_last_login_method_input(
220 context,
221 raw_body.get("lastLoginMethod"),
222 )?;
223
224 let mut writable_body = body.clone();
225 _ = writable_body.remove("email");
226 let update_req: UpdateUserRequest =
227 serde_json::from_value(serde_json::Value::Object(writable_body))
228 .map_err(|e| AuthError::bad_request(format!("Invalid JSON: {e}")))?;
229 let policy = context.extensions.get::<UsernameConfig>();
230 if let Some(policy) = &policy {
231 if let Some(value) = &update_req.username {
232 policy.validate_hook_value(value).await?;
233 let normalized = policy.normalize(value)?;
234 let current_username = current_user.username();
235 if policy.immutable_username
236 && current_username
237 .as_ref()
238 .is_some_and(|value| !value.is_empty() && *value != normalized)
239 {
240 return Err(AuthError::Upstream {
241 status: 400,
242 code: codes::USERNAME_IS_IMMUTABLE,
243 message: "Username cannot be updated",
244 });
245 }
246 if let Some(existing_user) = context.database.get_user_by_username(&normalized).await?
247 && existing_user.id() != current_user.id()
248 {
249 return Err(AuthError::Upstream {
250 status: 400,
251 code: codes::USERNAME_IS_ALREADY_TAKEN,
252 message: "Username is already taken. Please try another.",
253 });
254 }
255 }
256 if let Some(value) = &update_req.display_username {
257 policy.validate_display(value).await?;
258 }
259 }
260 let mut input_fields = raw_body
261 .as_object()
262 .ok_or_else(|| AuthError::bad_request("Invalid JSON object"))?
263 .clone();
264 if policy.is_none() {
265 _ = input_fields.shift_remove("username");
266 }
267 if policy
268 .as_ref()
269 .is_none_or(|policy| !policy.include_display_username)
270 {
271 _ = input_fields.shift_remove("displayUsername");
272 }
273 let additional_fields = context
274 .parse_user_fields(&input_fields, false)
275 .map_err(crate::authentication_helpers::field_input_error)?;
276 let username = additional_fields
277 .get("username")
278 .and_then(JsValue::as_str)
279 .map(str::to_owned);
280 let display_username = additional_fields
281 .get("displayUsername")
282 .and_then(JsValue::as_str)
283 .map(str::to_owned);
284
285 let clear_phone = context
286 .get_metadata("phone-number.enabled")
287 .and_then(serde_json::Value::as_bool)
288 == Some(true)
289 && body.get("phoneNumber") == Some(&serde_json::Value::Null);
290 let clear_image = body.get("image") == Some(&serde_json::Value::Null);
291 let has_changes = additional_fields.has_input_fields()
292 || clear_image
293 || clear_phone
294 || update_req.name.is_some()
295 || update_req.image.is_some()
296 || username.is_some()
297 || display_username.is_some()
298 || update_req.metadata.is_some();
299 if !has_changes {
300 return Err(AuthError::bad_request("No fields to update"));
301 }
302
303 let update_user = UpdateUser {
304 additional_fields,
305 provider_image: clear_image.then_some(serde_json::Value::Null),
306 phone_number: clear_phone.then_some(None),
307 name: update_req.name,
308 image: update_req.image,
309 username,
310 display_username,
311 metadata: update_req.metadata,
312 ..Default::default()
313 };
314
315 let publication = match context
316 .database
317 .update_user_record(¤t_user.id(), update_user.clone())
318 .await
319 {
320 Ok(updated_user) => alibi_core::CacheVersionContext::created(
321 updated_user.clone(),
322 current_session.clone(),
323 context.user_view(&updated_user),
324 current_session.clone(),
325 ),
326 Err(AuthError::UserNotFound) => {
327 let mut user = context.user_view(¤t_user);
330 if let Some(name) = update_user.name {
331 user.name = Some(name);
332 }
333 if clear_image {
334 user.image = None;
335 }
336 if let Some(image) = update_user.image {
337 user.image = Some(image);
338 }
339 if let Some(username) = update_user.username {
340 user.username = Some(username);
341 }
342 if let Some(display_username) = update_user.display_username {
343 user.display_username = Some(display_username);
344 }
345 if let Some(metadata) = update_user.metadata {
346 user.metadata = metadata;
347 }
348 for (name, value) in update_user.additional_fields {
349 _ = user.extension_fields.insert(name, value.to_json_value()?);
350 }
351 if let Some(phone_number) = update_user.phone_number {
352 user.phone_number = phone_number;
353 _ = user
354 .extension_fields
355 .insert("phoneNumber".into(), serde_json::Value::Null);
356 }
357 alibi_core::CacheVersionContext::created(
358 user.clone(),
359 current_session.clone(),
360 user,
361 current_session.clone(),
362 )
363 }
364 Err(error) => return Err(error),
365 };
366 alibi_core::session::cookie_cache::runtime::emit_issuance_snapshot(context, publication)
367 .await?;
368
369 let mut response = AuthResponse::json(200, &alibi_core::StatusResponse { status: true })?;
370
371 if let Some(token) = context.session_manager().extract_session_token(req) {
372 let cookie_header =
373 alibi_core::utils::cookie_utils::create_session_cookie(&token, &context.config)?;
374 response = response.with_header("Set-Cookie", cookie_header);
375 }
376
377 Ok(response)
378}