firebase_admin/auth/users/
operations.rs1use 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
12pub 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 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 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 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 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 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 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 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 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 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 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 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}