Skip to main content

alibi_plugins/user_management/
update.rs

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