Skip to main content

alibi_plugins/user_management/
update.rs

1use 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
11/// Build the builtin profile-update operation, including configured input fields.
12///
13/// # Errors
14/// Returns an error if the logical body cannot be serialized.
15pub 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/// Build the public health-check operation without requiring credentials or input.
22#[must_use]
23pub fn ok_endpoint() -> alibi_core::endpoint::ServerEndpoint<alibi_core::OkResponse> {
24    alibi_core::endpoint::ServerEndpoint::new("core", "ok")
25}
26
27/// Build the public error-page operation. Supply a genuine request with its original URL.
28#[must_use]
29pub fn error_endpoint() -> alibi_core::endpoint::ServerEndpoint<alibi_core::AuthResponse> {
30    alibi_core::endpoint::ServerEndpoint::new("core", "error")
31}
32
33/// Builtin owner used by the host runtime independently of optional user plugins.
34#[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        // Logical ctx.json returns a value; HTTP serialization adds its content type.
129        // Application headers remain in the actual endpoint response accumulator.
130        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
149/// Handle user profile update through the shared profile lifecycle.
150///
151/// # Errors
152/// Returns actual input, authentication, adapter, or publication failures.
153pub async fn handle_update_user<S: alibi_core::AuthSchema>(
154    req: &AuthRequest,
155    context: &AuthContext<S>,
156) -> AuthResult<AuthResponse> {
157    // Preserve the HTTP owner's admission before JSON decoding.
158    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    // Role changes require the administrator's authorization, independently
210    // of any application-defined additional field policy.
211    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(&current_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            // Source retains the authenticated output snapshot when the
328            // adapter no longer has this user. This does not recreate a row.
329            let mut user = context.user_view(&current_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}