Skip to main content

firebase_admin/auth/users/
operations.rs

1//! User management operations against the Identity Toolkit REST API.
2
3use crate::auth::error::{parse_identity_toolkit_response, AuthError};
4use crate::auth::identity_toolkit::requests::{
5    AccountsResponse, DeleteRequest, LookupRequest, SignUpRequest, UpdateRequest,
6};
7use crate::auth::identity_toolkit::IdentityToolkitEndpoints;
8use crate::auth::users::model::{CreateUserRequest, UpdateUserRequest, UserRecord};
9use crate::auth::users::query::UserPage;
10use crate::core::{CoreError, HttpClient};
11
12/// Performs user-management calls against the Identity Toolkit REST API.
13///
14/// Requires an OAuth2 bearer token (obtained from the configured service
15/// account or Application Default Credentials) on every request except when
16/// targeting the Firebase Auth Emulator, which instead requires a `key=`
17/// query parameter to be present (any value; the emulator does not validate
18/// it) — see [`crate::auth::mode::ClientMode::emulator_api_key`].
19pub struct UserOperations<'a> {
20    http: &'a HttpClient,
21    endpoints: &'a IdentityToolkitEndpoints,
22    bearer_token: Option<&'a str>,
23    emulator_api_key: Option<&'static str>,
24}
25
26impl<'a> UserOperations<'a> {
27    /// Creates a new set of user operations bound to the given transport
28    /// and (when talking to production) bearer token, or (when talking to
29    /// the emulator) dummy API key.
30    pub fn new(
31        http: &'a HttpClient,
32        endpoints: &'a IdentityToolkitEndpoints,
33        bearer_token: Option<&'a str>,
34        emulator_api_key: Option<&'static str>,
35    ) -> Self {
36        Self {
37            http,
38            endpoints,
39            bearer_token,
40            emulator_api_key,
41        }
42    }
43
44    fn request(&self, url: &str) -> reqwest::RequestBuilder {
45        self.authorize(self.http.inner().post(url))
46    }
47
48    /// Like [`Self::request`], but issues a `GET` instead of a `POST` — only
49    /// `accounts:batchGet` (list users) uses `GET`; every other Identity
50    /// Toolkit `accounts:*` operation here is a `POST`.
51    fn get_request(&self, url: &str) -> reqwest::RequestBuilder {
52        self.authorize(self.http.inner().get(url))
53    }
54
55    fn authorize(&self, mut builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
56        if let Some(token) = self.bearer_token {
57            builder = builder.bearer_auth(token);
58        }
59        if let Some(key) = self.emulator_api_key {
60            builder = builder.query(&[("key", key)]);
61        }
62        builder
63    }
64
65    /// Fetches a single user by uid.
66    pub async fn get_user(&self, uid: &str) -> Result<UserRecord, AuthError> {
67        let request = LookupRequest {
68            local_id: vec![uid.to_string()],
69            email: vec![],
70        };
71        let response = self
72            .request(&self.endpoints.lookup())
73            .json(&request)
74            .send()
75            .await?;
76        let parsed: AccountsResponse = parse_identity_toolkit_response(response).await?;
77        parsed
78            .users
79            .into_iter()
80            .next()
81            .map(UserRecord::from)
82            .ok_or(AuthError::UserNotFound)
83    }
84
85    /// Fetches a single user by email address.
86    pub async fn get_user_by_email(&self, email: &str) -> Result<UserRecord, AuthError> {
87        let request = LookupRequest {
88            local_id: vec![],
89            email: vec![email.to_string()],
90        };
91        let response = self
92            .request(&self.endpoints.lookup())
93            .json(&request)
94            .send()
95            .await?;
96        let parsed: AccountsResponse = parse_identity_toolkit_response(response).await?;
97        parsed
98            .users
99            .into_iter()
100            .next()
101            .map(UserRecord::from)
102            .ok_or(AuthError::UserNotFound)
103    }
104
105    /// Creates a new user.
106    pub async fn create_user(&self, request: CreateUserRequest) -> Result<UserRecord, AuthError> {
107        let body = SignUpRequest {
108            local_id: request.uid,
109            email: request.email,
110            password: request.password,
111            display_name: request.display_name,
112            disabled: request.disabled,
113        };
114        let response = self
115            .request(&self.endpoints.sign_up())
116            .json(&body)
117            .send()
118            .await?;
119        let local_id: serde_json::Value = parse_identity_toolkit_response(response).await?;
120        let uid = local_id
121            .get("localId")
122            .and_then(|v| v.as_str())
123            .ok_or_else(|| AuthError::Api {
124                status: 200,
125                message: "signUp response missing localId".to_string(),
126                error_code: None,
127            })?;
128        self.get_user(uid).await
129    }
130
131    /// Updates an existing user, including replacing its custom claims.
132    pub async fn update_user(
133        &self,
134        uid: &str,
135        request: UpdateUserRequest,
136    ) -> Result<UserRecord, AuthError> {
137        let custom_attributes = request
138            .custom_claims
139            .map(|claims| serde_json::to_string(&claims))
140            .transpose()
141            .map_err(CoreError::Deserialize)?;
142
143        let body = UpdateRequest {
144            local_id: uid.to_string(),
145            email: request.email,
146            display_name: request.display_name,
147            disable_user: request.disabled,
148            custom_attributes,
149        };
150        let response = self
151            .request(&self.endpoints.update())
152            .json(&body)
153            .send()
154            .await?;
155        let _: serde_json::Value = parse_identity_toolkit_response(response).await?;
156        self.get_user(uid).await
157    }
158
159    /// Replaces a user's custom claims entirely.
160    pub async fn set_custom_user_claims(
161        &self,
162        uid: &str,
163        claims: serde_json::Map<String, serde_json::Value>,
164    ) -> Result<(), AuthError> {
165        self.update_user(
166            uid,
167            UpdateUserRequest {
168                custom_claims: Some(claims),
169                ..Default::default()
170            },
171        )
172        .await?;
173        Ok(())
174    }
175
176    /// Deletes a user by uid.
177    pub async fn delete_user(&self, uid: &str) -> Result<(), AuthError> {
178        let body = DeleteRequest {
179            local_id: uid.to_string(),
180        };
181        let response = self
182            .request(&self.endpoints.delete())
183            .json(&body)
184            .send()
185            .await?;
186        let _: serde_json::Value = parse_identity_toolkit_response(response).await?;
187        Ok(())
188    }
189
190    /// Lists users, paginated via `next_page_token`.
191    pub async fn list_users(
192        &self,
193        max_results: u32,
194        page_token: Option<&str>,
195    ) -> Result<UserPage, AuthError> {
196        #[derive(serde::Serialize)]
197        struct BatchGetQuery<'a> {
198            #[serde(rename = "maxResults")]
199            max_results: u32,
200            #[serde(rename = "nextPageToken", skip_serializing_if = "Option::is_none")]
201            next_page_token: Option<&'a str>,
202        }
203
204        // accounts:batchGet is a GET request with query parameters, unlike
205        // every other Identity Toolkit accounts:* operation used here, which
206        // are POSTs with a JSON body — confirmed against the Firebase Auth
207        // Emulator's own API spec after the POST+JSON form returned a 404.
208        let response = self
209            .get_request(&self.endpoints.batch_get())
210            .query(&BatchGetQuery {
211                max_results,
212                next_page_token: page_token,
213            })
214            .send()
215            .await?;
216        let parsed: AccountsResponse = parse_identity_toolkit_response(response).await?;
217
218        Ok(UserPage {
219            users: parsed.users.into_iter().map(UserRecord::from).collect(),
220            next_page_token: parsed.next_page_token,
221        })
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use serde_json::json;
229    use wiremock::matchers::{method, path};
230    use wiremock::{Mock, MockServer, ResponseTemplate};
231
232    async fn operations_against(server: &MockServer) -> (HttpClient, IdentityToolkitEndpoints) {
233        (
234            HttpClient::default(),
235            IdentityToolkitEndpoints::custom(server.uri()),
236        )
237    }
238
239    #[tokio::test]
240    async fn get_user_returns_the_matching_record() {
241        let server = MockServer::start().await;
242        Mock::given(method("POST"))
243            .and(path("/accounts:lookup"))
244            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
245                "users": [{
246                    "localId": "uid-1",
247                    "email": "user@example.com",
248                    "emailVerified": true,
249                }]
250            })))
251            .mount(&server)
252            .await;
253
254        let (http, endpoints) = operations_against(&server).await;
255        let ops = UserOperations::new(&http, &endpoints, Some("token"), None);
256
257        let user = ops.get_user("uid-1").await.unwrap();
258        assert_eq!(user.uid, "uid-1");
259        assert_eq!(user.email.as_deref(), Some("user@example.com"));
260        assert!(user.email_verified);
261    }
262
263    #[tokio::test]
264    async fn get_user_with_no_matches_is_user_not_found() {
265        let server = MockServer::start().await;
266        Mock::given(method("POST"))
267            .and(path("/accounts:lookup"))
268            .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "users": [] })))
269            .mount(&server)
270            .await;
271
272        let (http, endpoints) = operations_against(&server).await;
273        let ops = UserOperations::new(&http, &endpoints, Some("token"), None);
274
275        let err = ops.get_user("missing-uid").await.unwrap_err();
276        assert!(matches!(err, AuthError::UserNotFound));
277    }
278
279    #[tokio::test]
280    async fn create_user_looks_up_the_new_user_after_sign_up() {
281        let server = MockServer::start().await;
282        Mock::given(method("POST"))
283            .and(path("/accounts:signUp"))
284            .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "localId": "new-uid" })))
285            .mount(&server)
286            .await;
287        Mock::given(method("POST"))
288            .and(path("/accounts:lookup"))
289            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
290                "users": [{ "localId": "new-uid", "email": "new@example.com" }]
291            })))
292            .mount(&server)
293            .await;
294
295        let (http, endpoints) = operations_against(&server).await;
296        let ops = UserOperations::new(&http, &endpoints, Some("token"), None);
297
298        let user = ops
299            .create_user(CreateUserRequest {
300                email: Some("new@example.com".to_string()),
301                password: Some("correct horse battery staple".to_string()),
302                ..Default::default()
303            })
304            .await
305            .unwrap();
306        assert_eq!(user.uid, "new-uid");
307    }
308
309    #[tokio::test]
310    async fn delete_user_succeeds_on_a_200_response() {
311        let server = MockServer::start().await;
312        Mock::given(method("POST"))
313            .and(path("/accounts:delete"))
314            .respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
315            .mount(&server)
316            .await;
317
318        let (http, endpoints) = operations_against(&server).await;
319        let ops = UserOperations::new(&http, &endpoints, Some("token"), None);
320
321        ops.delete_user("uid-1").await.unwrap();
322    }
323
324    #[tokio::test]
325    async fn list_users_returns_records_and_next_page_token() {
326        let server = MockServer::start().await;
327        // accounts:batchGet is a GET with a projects/{projectId} path
328        // segment, unlike the flat POST-based accounts:* endpoints used
329        // elsewhere in this crate — see IdentityToolkitEndpoints::batch_get.
330        Mock::given(method("GET"))
331            .and(path("/projects/test-project/accounts:batchGet"))
332            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
333                "users": [
334                    { "localId": "uid-1" },
335                    { "localId": "uid-2" },
336                ],
337                "nextPageToken": "page-2",
338            })))
339            .mount(&server)
340            .await;
341
342        let (http, endpoints) = operations_against(&server).await;
343        let ops = UserOperations::new(&http, &endpoints, Some("token"), None);
344
345        let page = ops.list_users(10, None).await.unwrap();
346        assert_eq!(page.users.len(), 2);
347        assert_eq!(page.next_page_token.as_deref(), Some("page-2"));
348    }
349
350    #[tokio::test]
351    async fn a_structured_api_error_populates_error_code() {
352        let server = MockServer::start().await;
353        Mock::given(method("POST"))
354            .and(path("/accounts:signUp"))
355            .respond_with(ResponseTemplate::new(400).set_body_json(json!({
356                "error": {
357                    "code": 400,
358                    "message": "EMAIL_EXISTS",
359                    "errors": [{ "message": "EMAIL_EXISTS", "reason": "EMAIL_EXISTS" }]
360                }
361            })))
362            .mount(&server)
363            .await;
364
365        let (http, endpoints) = operations_against(&server).await;
366        let ops = UserOperations::new(&http, &endpoints, Some("token"), None);
367
368        let err = ops
369            .create_user(CreateUserRequest {
370                email: Some("taken@example.com".to_string()),
371                password: Some("correct horse battery staple".to_string()),
372                ..Default::default()
373            })
374            .await
375            .unwrap_err();
376
377        match err {
378            AuthError::Api {
379                status, error_code, ..
380            } => {
381                assert_eq!(status, 400);
382                assert_eq!(error_code.as_deref(), Some("EMAIL_EXISTS"));
383            }
384            other => panic!("expected AuthError::Api, got {other:?}"),
385        }
386    }
387}